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 21204396dedbeb220414b769d336ca8a995b7d1c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:02:27 +0000 Subject: [PATCH 2/3] pygrass: Load the grid doctests in the grid wrapper The doctest wrapper in pygrass/modules/grid/testsuite loaded no tests. It was a copy of the one for shortcuts with the only addTests() call commented out. The doctests of the grid module were run by the wrapper in pygrass/modules/testsuite instead. Load the doctests of grid, patch and split in the grid wrapper. The wrapper in pygrass/modules/testsuite then loaded only the interface package, which has no doctests (its modules are loaded by the wrapper in pygrass/modules/interface/testsuite), so remove it. The changes were developed with the help of an AI assistant (Claude). --- pyproject.toml | 1 - .../test_pygrass_modules_grid_doctests.py | 7 +-- .../test_pygrass_modules_doctests.py | 44 ------------------- 3 files changed, 4 insertions(+), 48 deletions(-) delete mode 100644 python/grass/pygrass/modules/testsuite/test_pygrass_modules_doctests.py diff --git a/pyproject.toml b/pyproject.toml index c9b8a7bb24a..1f7c998cdb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -439,7 +439,6 @@ known-first-party = ["grass"] "python/grass/pygrass/modules/grid/testsuite/test_*_modules_grid_doctests.py" = ["F401"] "python/grass/pygrass/modules/interface/env.py" = ["PLW1514", "SIM115"] "python/grass/pygrass/modules/interface/parameter.py" = ["PLR6201"] -"python/grass/pygrass/modules/testsuite/test_pygrass_modules_doctests.py" = ["F401"] "python/grass/pygrass/raster/category.py" = ["FURB189", "PLW1514"] "python/grass/pygrass/raster/segment.py" = ["PLW1514", "SIM115"] "python/grass/pygrass/tests/*.py" = ["SIM115"] diff --git a/python/grass/pygrass/modules/grid/testsuite/test_pygrass_modules_grid_doctests.py b/python/grass/pygrass/modules/grid/testsuite/test_pygrass_modules_grid_doctests.py index 5034c2de3f9..ebea878fec3 100644 --- a/python/grass/pygrass/modules/grid/testsuite/test_pygrass_modules_grid_doctests.py +++ b/python/grass/pygrass/modules/grid/testsuite/test_pygrass_modules_grid_doctests.py @@ -8,7 +8,7 @@ import grass.gunittest.main import grass.gunittest.utils -import grass.pygrass.modules as gmodules +from grass.pygrass.modules.grid import grid, patch, split # doctest does not allow changing the base classes of test case, skip test case @@ -31,8 +31,9 @@ def load_tests(loader, tests, ignore): # TODO: ultimate solution is not to use _ as a builtin in lib/python # for now it is the only place where it works grass.gunittest.utils.do_doctest_gettext_workaround() - - # tests.addTests(doctest.DocTestSuite(gmodules.shortcuts)) + tests.addTests(doctest.DocTestSuite(grid)) + tests.addTests(doctest.DocTestSuite(patch)) + tests.addTests(doctest.DocTestSuite(split)) return tests diff --git a/python/grass/pygrass/modules/testsuite/test_pygrass_modules_doctests.py b/python/grass/pygrass/modules/testsuite/test_pygrass_modules_doctests.py deleted file mode 100644 index 77b49fef82f..00000000000 --- a/python/grass/pygrass/modules/testsuite/test_pygrass_modules_doctests.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Tests checkers -""" - -import doctest - -import grass.gunittest.case -import grass.gunittest.main -import grass.gunittest.utils - -from grass.pygrass.modules import shortcuts, grid, interface - - -# doctest does not allow changing the base classes of test case, skip test case -# and test suite, so we need to create a new type which inherits from our class -# and contains doctest's methods -# the alternative is to copy 500 from doctest and change what is needed -# (this might be necessary anyway because of the reports and stdout and stderr) -doctest.DocFileCase = type( - "DocFileCase", (grass.gunittest.case.TestCase,), dict(doctest.DocFileCase.__dict__) -) -doctest.SkipDocTestCase = type( - "SkipDocTestCase", - (grass.gunittest.case.TestCase,), - dict(doctest.SkipDocTestCase.__dict__), -) - - -def load_tests(loader, tests, ignore): - # TODO: this must be somewhere when doctest is called, not here - # TODO: ultimate solution is not to use _ as a builtin in lib/python - # for now it is the only place where it works - grass.gunittest.utils.do_doctest_gettext_workaround() - # this should be called at some top level - tests.addTests(doctest.DocTestSuite(interface)) - tests.addTests(doctest.DocTestSuite(grid.grid)) - tests.addTests(doctest.DocTestSuite(grid.patch)) - tests.addTests(doctest.DocTestSuite(grid.split)) - # tests.addTests(doctest.DocTestSuite(shortcuts)) - return tests - - -if __name__ == "__main__": - grass.gunittest.main.test() From 2331339502aaea48eacb9357c89423dc1373dd1e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:27:53 +0000 Subject: [PATCH 3/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()