From 90da182565938d7943a6be3265df8e12dd1b49b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 12:42:12 +0000 Subject: [PATCH 1/3] pygrass: Fix doctests so they run in gunittest again Enable the gunittest, pygrass raster and pygrass rpc doctest wrappers that were excluded in .gunittest.cfg. - RowIO: Build the Rowio_setup() callbacks from the type declared by the generated binding and accept the fourth (row length) argument. The three-argument callbacks were rejected by ctypes or crashed. - Buffer: Make __array_wrap__ work with NumPy 2, which passes a plain ndarray and an additional return_scalar argument. - Columns: Drop the database connection when pickling, so that the RPC data provider can send the columns to the client process. - GridModule: Look at the parameter typedesc instead of its Python type when adding the mapset to input maps, and handle multiple values and vector maps. Before, inputs never got a mapset and tiles failed. - get_raster_for_points: Do not depend on the NumPy scalar repr in the doctest. The changes were developed with the help of an AI assistant (Claude). --- .gunittest.cfg | 3 --- python/grass/pygrass/modules/grid/grid.py | 17 ++++++++++++----- python/grass/pygrass/raster/buffer.py | 10 +++++++--- python/grass/pygrass/raster/rowio.py | 11 +++++++---- python/grass/pygrass/utils.py | 4 ++-- python/grass/pygrass/vector/table.py | 21 +++++++++++++++++++++ 6 files changed, 49 insertions(+), 17 deletions(-) diff --git a/.gunittest.cfg b/.gunittest.cfg index 3655f2d4e37..b6ecda06c3a 100644 --- a/.gunittest.cfg +++ b/.gunittest.cfg @@ -5,9 +5,6 @@ # but it includes mainly tests which can (and should) be fixed. exclude = gui/wxpython/core/testsuite/toolboxes.sh - python/grass/gunittest/testsuite/test_gunitest_doctests.py - python/grass/pygrass/raster/testsuite/test_pygrass_raster_doctests.py - python/grass/pygrass/rpc/testsuite/test_pygrass_rpc_doctests.py python/grass/temporal/testsuite/unittests_temporal_raster_algebra_equal_ts.py python/grass/temporal/testsuite/unittests_temporal_raster_conditionals_complement_else.py raster/r.in.lidar/testsuite/test_base_resolution.sh diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index e3c3c3ebdbc..c28ec3c9c09 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -12,7 +12,7 @@ from grass.pygrass.gis import Mapset, Location from grass.pygrass.gis.region import Region from grass.pygrass.modules import Module -from grass.pygrass.utils import get_mapset_raster, findmaps +from grass.pygrass.utils import findmaps, get_mapset_raster, get_mapset_vector from grass.pygrass.modules.grid.split import ( split_region_tiles, @@ -651,10 +651,17 @@ def define_mapset_inputs(self): """Add the mapset information to the input maps""" for inmap in self.module.inputs: inm = self.module.inputs[inmap] - if inm.type in {"raster", "vector"} and inm.value: - if "@" not in inm.value: - mset = get_mapset_raster(inm.value) - inm.value += "@%s" % mset + if inm.typedesc not in {"raster", "vector"} or not inm.value: + continue + find_mapset = ( + get_mapset_raster if inm.typedesc == "raster" else get_mapset_vector + ) + names = inm.value if isinstance(inm.value, list) else [inm.value] + qualified = [ + name if "@" in name else "%s@%s" % (name, find_mapset(name)) + for name in names + ] + inm.value = qualified if isinstance(inm.value, list) else qualified[0] def run(self, patch=True, clean=True): """Run the GRASS command diff --git a/python/grass/pygrass/raster/buffer.py b/python/grass/pygrass/raster/buffer.py index 67ba86563bc..ce8aa36b76c 100644 --- a/python/grass/pygrass/raster/buffer.py +++ b/python/grass/pygrass/raster/buffer.py @@ -43,12 +43,16 @@ def __array_finalize__(self, obj): self.pointer_type = getattr(obj, "pointer_type", None) self.p = getattr(obj, "p", None) - def __array_wrap__(self, out_arr, context=None): + def __array_wrap__(self, out_arr, context=None, *args): """See: https://numpy.org/doc/stable/user/basics.subclassing.html#array-wrap-for-ufuncs-and-other-functions """ if out_arr.dtype == bool: # there is not support for boolean maps, so convert into integer out_arr = out_arr.astype(np.int32) - out_arr.p = out_arr.ctypes.data_as(out_arr.pointer_type) - return np.ndarray.__array_wrap__(self, out_arr, context) + # NumPy 2 passes a plain ndarray (and an additional return_scalar + # argument), so the pointer can be set only after wrapping into Buffer. + wrapped = np.ndarray.__array_wrap__(self, out_arr, context, *args) + if isinstance(wrapped, Buffer): + wrapped.p = wrapped.ctypes.data_as(wrapped.pointer_type) + return wrapped diff --git a/python/grass/pygrass/raster/rowio.py b/python/grass/pygrass/raster/rowio.py index a49da7efe26..e1c3c6413d8 100644 --- a/python/grass/pygrass/raster/rowio.py +++ b/python/grass/pygrass/raster/rowio.py @@ -13,20 +13,23 @@ from grass.pygrass.raster.raster_type import TYPE as RTYPE -CMPFUNC = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_int) +# The callback type must be the one the generated binding declares for +# Rowio_setup(). It takes the row length as a fourth argument, and an equivalent +# type created with ctypes.CFUNCTYPE() is rejected by ctypes. +CMPFUNC = librowio.Rowio_setup.argtypes[4] -def getmaprow_CELL(fd, buf, row): +def getmaprow_CELL(fd, buf, row, length): librast.Rast_get_c_row(fd, ctypes.cast(buf, ctypes.POINTER(librast.CELL)), row) return 1 -def getmaprow_FCELL(fd, buf, row): +def getmaprow_FCELL(fd, buf, row, length): librast.Rast_get_f_row(fd, ctypes.cast(buf, ctypes.POINTER(librast.FCELL)), row) return 1 -def getmaprow_DCELL(fd, buf, row): +def getmaprow_DCELL(fd, buf, row, length): librast.Rast_get_d_row(fd, ctypes.cast(buf, ctypes.POINTER(librast.DCELL)), row) return 1 diff --git a/python/grass/pygrass/utils.py b/python/grass/pygrass/utils.py index 344e4237ea4..bc34b948981 100644 --- a/python/grass/pygrass/utils.py +++ b/python/grass/pygrass/utils.py @@ -278,9 +278,9 @@ def get_raster_for_points(poi_vector, raster, column=None, region=None): >>> l = get_raster_for_points(vect, ele, region=region) >>> l[0] # doctest: +ELLIPSIS - (1, 10.0, 6.0, 1) + (1, 10.0, 6.0, ...) >>> l[1] # doctest: +ELLIPSIS - (2, 12.0, 6.0, 1) + (2, 12.0, 6.0, ...) Add a new column and sample again diff --git a/python/grass/pygrass/vector/table.py b/python/grass/pygrass/vector/table.py index ea54af90841..f52d734615c 100644 --- a/python/grass/pygrass/vector/table.py +++ b/python/grass/pygrass/vector/table.py @@ -207,6 +207,27 @@ def __init__(self, tname, connection, key="cat"): self.odict = None self.update_odict() + def __getstate__(self): + """Return the state for pickling without the database connection + + The connection cannot be pickled, so an unpickled object is a snapshot + of the columns which can be read but not updated. + + >>> import pickle + >>> import sqlite3 + >>> from grass.pygrass.vector.table import get_path + >>> path = "$GISDBASE/$LOCATION_NAME/$MAPSET/sqlite/sqlite.db" + >>> cols = Columns(test_vector_name, sqlite3.connect(get_path(path))) + >>> copy = pickle.loads(pickle.dumps(cols)) + >>> copy == cols + True + >>> copy.conn is None + True + """ + state = self.__dict__.copy() + state["conn"] = None + return state + def __contains__(self, item): return item in self.names() From 2331339502aaea48eacb9357c89423dc1373dd1e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:27:53 +0000 Subject: [PATCH 2/3] grass.pygrass: Close the vector before reopening it in a doctest get_raster_for_points() opens the vector map if it is not open and does not close it. The doctest then opened the map again without closing it, so the first handle stayed open. Windows cannot delete files which are open, so the final remove() failed with "Unable to delete file" and the doctest wrapper failed on Windows. The change was developed with the help of an AI assistant (Claude). --- python/grass/pygrass/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/grass/pygrass/utils.py b/python/grass/pygrass/utils.py index bc34b948981..aaa969f87f4 100644 --- a/python/grass/pygrass/utils.py +++ b/python/grass/pygrass/utils.py @@ -282,8 +282,12 @@ def get_raster_for_points(poi_vector, raster, column=None, region=None): >>> l[1] # doctest: +ELLIPSIS (2, 12.0, 6.0, ...) + The function opens the vector map if it is not open, so close it before + opening it again. A map which is still open cannot be removed on Windows. + Add a new column and sample again + >>> vect.close() >>> vect.open("r") >>> vect.table.columns.add(test_raster_name, "double precision") >>> vect.table.conn.commit() From 34034ec1b9cdaf02c9382ea98a53a6094cbbe886 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:09:44 +0000 Subject: [PATCH 3/3] grass.pygrass: Clarify the review feedback spots Split GridModule.define_mapset_inputs() into simpler steps with a helper that qualifies a map name, explain why the Rowio callback type comes from the binding, and close the vector right after the call that opened it in the get_raster_for_points() doctest. --- python/grass/pygrass/modules/grid/grid.py | 31 +++++++++++++++-------- python/grass/pygrass/raster/rowio.py | 8 +++--- python/grass/pygrass/utils.py | 7 ++--- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index c28ec3c9c09..a54086dab62 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -650,18 +650,27 @@ def get_works(self): def define_mapset_inputs(self): """Add the mapset information to the input maps""" for inmap in self.module.inputs: - inm = self.module.inputs[inmap] - if inm.typedesc not in {"raster", "vector"} or not inm.value: + param = self.module.inputs[inmap] + if param.typedesc == "raster": + get_mapset = get_mapset_raster + elif param.typedesc == "vector": + get_mapset = get_mapset_vector + else: continue - find_mapset = ( - get_mapset_raster if inm.typedesc == "raster" else get_mapset_vector - ) - names = inm.value if isinstance(inm.value, list) else [inm.value] - qualified = [ - name if "@" in name else "%s@%s" % (name, find_mapset(name)) - for name in names - ] - inm.value = qualified if isinstance(inm.value, list) else qualified[0] + if not param.value: + continue + + if isinstance(param.value, list): + param.value = [self._with_mapset(n, get_mapset) for n in param.value] + else: + param.value = self._with_mapset(param.value, get_mapset) + + @staticmethod + def _with_mapset(name, get_mapset): + """Return the map name qualified with its mapset, e.g. ``name@mapset``""" + if "@" in name: + return name + return "%s@%s" % (name, get_mapset(name)) def run(self, patch=True, clean=True): """Run the GRASS command diff --git a/python/grass/pygrass/raster/rowio.py b/python/grass/pygrass/raster/rowio.py index e1c3c6413d8..72cb54b6533 100644 --- a/python/grass/pygrass/raster/rowio.py +++ b/python/grass/pygrass/raster/rowio.py @@ -13,9 +13,11 @@ from grass.pygrass.raster.raster_type import TYPE as RTYPE -# The callback type must be the one the generated binding declares for -# Rowio_setup(). It takes the row length as a fourth argument, and an equivalent -# type created with ctypes.CFUNCTYPE() is rejected by ctypes. +# Rowio_setup() takes a callback which reads one row into a buffer. The +# generated binding declares the type of that callback (a ctypes function +# prototype with the arguments fd, buffer, row and length). The functions below +# have to be wrapped with this exact type: a prototype made here with +# ctypes.CFUNCTYPE() is a different class, and ctypes rejects it as an argument. CMPFUNC = librowio.Rowio_setup.argtypes[4] diff --git a/python/grass/pygrass/utils.py b/python/grass/pygrass/utils.py index aaa969f87f4..0467c2a6006 100644 --- a/python/grass/pygrass/utils.py +++ b/python/grass/pygrass/utils.py @@ -282,12 +282,13 @@ def get_raster_for_points(poi_vector, raster, column=None, region=None): >>> l[1] # doctest: +ELLIPSIS (2, 12.0, 6.0, ...) - The function opens the vector map if it is not open, so close it before - opening it again. A map which is still open cannot be removed on Windows. + The function opened the vector map because it was not open. Close it + again, since a map which is still open cannot be removed on Windows. + + >>> vect.close() Add a new column and sample again - >>> vect.close() >>> vect.open("r") >>> vect.table.columns.add(test_raster_name, "double precision") >>> vect.table.conn.commit()