From 989fdc7f86b01c7ad63ab0264eb6b5d4be2f30e0 Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 14 Sep 2026 08:48:54 +0200 Subject: [PATCH 01/18] remove commented out code --- python/grass/temporal/base.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/python/grass/temporal/base.py b/python/grass/temporal/base.py index 3504844999f..54eb06f39ce 100644 --- a/python/grass/temporal/base.py +++ b/python/grass/temporal/base.py @@ -289,7 +289,6 @@ def delete(self, dbif=None) -> None: if None a temporary connection will be established """ sql = self.get_delete_statement() - # print(sql) # must use the temporal database of the current mapset, # also if the map to be deleted is in a different mapset @@ -379,8 +378,6 @@ def select(self, dbif=None, mapset=None) -> bool: if None a temporary connection will be established """ sql, args = self.get_select_statement() - # print(sql) - # print(args) # default: use the temporal database in the mapset of this map if mapset is None: @@ -446,8 +443,6 @@ def insert(self, dbif=None) -> None: if None a temporary connection will be established """ sql, args = self.get_insert_statement() - # print(sql) - # print(args) # use the temporal database in the current mapset mapset = get_current_mapset() @@ -511,8 +506,6 @@ def update(self, dbif=None, ident=None) -> None: mapset = get_current_mapset() sql, args = self.get_update_statement(ident) - # print(sql) - # print(args) if dbif: dbif.execute(sql, args, mapset=mapset) @@ -567,8 +560,6 @@ def update_all(self, dbif=None, ident=None) -> None: mapset = get_current_mapset() sql, args = self.get_update_all_statement(ident) - # print(sql) - # print(args) if dbif: dbif.execute(sql, args, mapset=mapset) From 50b988d289429095ef7c8ebb13d4084437c6412b Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 14 Sep 2026 08:50:54 +0200 Subject: [PATCH 02/18] limnit connection and simplify --- temporal/t.remove/t.remove.py | 36 +++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/temporal/t.remove/t.remove.py b/temporal/t.remove/t.remove.py index b9128c12bd5..314cd7319a7 100755 --- a/temporal/t.remove/t.remove.py +++ b/temporal/t.remove/t.remove.py @@ -57,6 +57,10 @@ # % description: Remove stds, unregister maps from temporal database and delete them from mapset # %end +# %rules +# % exclusive: inputs, file +# %end + import grass.script as gs # lazy imports at the end of the file @@ -73,23 +77,28 @@ def main(): force = flags["f"] clean = flags["d"] - if datasets and file: - gs.fatal(_("%s= and %s= are mutually exclusive") % ("input", "file")) + mapset = gs.gisenv()["MAPSET"] - # Make sure the temporal database exists - tgis.init() + # Initialize TGIS, the temporal database has to exist already + tgis.init(skip_db_init=True) - dbif = tgis.SQLDatabaseInterfaceConnection() + dbif = tgis.SQLDatabaseInterfaceConnection(mapsets=mapset) + + # No TGIS DB found in the current mapset + if not dbif.tgis_mapsets: + gs.message( + _( + "No temporal database found in the requested mapset(s). No datasets to list." + ) + ) + return dbif.connect() dataset_list = [] # Dataset names as comma separated string if datasets: - if datasets.find(",") == -1: - dataset_list = (datasets,) - else: - dataset_list = tuple(datasets.split(",")) + dataset_list = datasets.split(",") if file: line = True @@ -132,15 +141,14 @@ def main(): "from spatial database:" ) - if recursive or clean: - gs.message(msg.format(stds=sp.get_type(), gid=sp.get_id())) + gs.message(msg.format(stds=sp.get_type(), gid=sp.get_id())) maps = sp.get_registered_maps_as_objects(dbif=dbif) map_statement = "" count = 1 name_list = [] for map in maps: - map.select(dbif) + map.select(dbif, mapset=mapset) # We may have multiple layer for a single map, hence we need # to avoid multiple deletions of the same map, # but the database entries are still present and must be removed @@ -167,7 +175,7 @@ def main(): name_list = [] if map_statement: - dbif.execute_transaction(map_statement) + dbif.execute_transaction(map_statement, mapset=mapset) if clean and name_list: if type == "strds": remove(type="raster", name=name_list, run_=True) @@ -187,7 +195,7 @@ def main(): ) else: # Execute the collected SQL statenents - dbif.execute_transaction(statement) + dbif.execute_transaction(statement, mapset=mapset) dbif.close() From 1264fe0b604a8a43677cb7745779468d01f9b0fa Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 14 Sep 2026 08:52:49 +0200 Subject: [PATCH 03/18] limnit connection and simplify --- temporal/t.rename/t.rename.py | 39 ++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/temporal/t.rename/t.rename.py b/temporal/t.rename/t.rename.py index a6d8d33be77..67906d5a9d7 100755 --- a/temporal/t.rename/t.rename.py +++ b/temporal/t.rename/t.rename.py @@ -6,7 +6,7 @@ # AUTHOR(S): Soeren Gebbert # # PURPOSE: Renames a space time dataset -# SPDX-FileCopyrightText: 2011-2017 GRASS Development Team +# SPDX-FileCopyrightText: 2011-2026 GRASS Development Team # SPDX-License-Identifier: GPL-2.0-or-later # ############################################################################# @@ -44,20 +44,21 @@ def main(): output = options["output"] type = options["type"] - # Make sure the temporal database exists - tgis.init() - # Get the current mapset to create the id of the space time dataset mapset = gs.gisenv()["MAPSET"] - old_id = input if input.find("@") >= 0 else input + "@" + mapset - new_id = output if output.find("@") >= 0 else output + "@" + mapset + old_id = input if "@" in input else f"{input}@{mapset}" + new_id = output if "@" in output else f"{output}@{mapset}" # Do not overwrite yourself if new_id == old_id: return - dbif = tgis.SQLDatabaseInterfaceConnection() + # Try initializing the temporal database in the current mapset + tgis.init(skip_db_init=True) + dbif = tgis.SQLDatabaseInterfaceConnection(mapsets=mapset) + if not dbif.tgis_mapsets: + gs.fatal(_("No temporal database found in the current mapset.")) dbif.connect() stds = tgis.dataset_factory(type, old_id) @@ -72,7 +73,7 @@ def main(): % (stds.get_new_map_instance(None).get_type(), old_id) ) - if not stds.is_in_db(dbif=dbif): + if not stds.is_in_db(dbif=dbif, mapset=mapset): dbif.close() gs.fatal( _("Space time %s dataset <%s> not found") @@ -82,21 +83,21 @@ def main(): # Check if the new id is in the database new_stds = tgis.dataset_factory(type, new_id) - if new_stds.is_in_db(dbif=dbif) and not gs.overwrite(): - dbif.close() - gs.fatal( - _( - "Unable to rename Space time %s dataset <%s>. Name <%s> " - "is in use, please use the overwrite flag." + if new_stds.is_in_db(dbif=dbif, mapset=mapset): + if not gs.overwrite(): + dbif.close() + gs.fatal( + _( + "Unable to rename Space time %s dataset <%s>. Name <%s> " + "is in use, please use the overwrite flag." + ) + % (stds.get_new_map_instance(None).get_type(), old_id, new_id) ) - % (stds.get_new_map_instance(None).get_type(), old_id, new_id) - ) - # Remove an already existing space time dataset - if new_stds.is_in_db(dbif=dbif): + # Remove an already existing space time dataset new_stds.delete(dbif=dbif) - stds.select(dbif=dbif) + stds.select(dbif=dbif, mapset=mapset) stds.rename(ident=new_id, dbif=dbif) stds.update_command_string(dbif=dbif) From 434b9368816bc7798d56269865cace3c79de977b Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 14 Sep 2026 08:54:54 +0200 Subject: [PATCH 04/18] limnit connection --- temporal/t.shift/t.shift.py | 9 ++++++--- temporal/t.snap/t.snap.py | 9 ++++++--- temporal/t.support/t.support.py | 11 +++++++---- temporal/t.upgrade/t.upgrade.py | 8 ++++++-- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/temporal/t.shift/t.shift.py b/temporal/t.shift/t.shift.py index 5fea088848b..d82803c81a3 100755 --- a/temporal/t.shift/t.shift.py +++ b/temporal/t.shift/t.shift.py @@ -50,10 +50,13 @@ def main(): type = options["type"] gran = options["granularity"] - # Make sure the temporal database exists - tgis.init() + mapset = gs.gisenv()["MAPSET"] - dbif = tgis.SQLDatabaseInterfaceConnection() + # Try initializing the temporal database in the current mapset + tgis.init(skip_db_init=True) + dbif = tgis.SQLDatabaseInterfaceConnection(mapsets=mapset) + if not dbif.tgis_mapsets: + gs.fatal(_("No temporal database found in the current mapset.")) dbif.connect() stds = tgis.open_old_stds(name, type, dbif) diff --git a/temporal/t.snap/t.snap.py b/temporal/t.snap/t.snap.py index 4b8583376a5..4032a1af823 100755 --- a/temporal/t.snap/t.snap.py +++ b/temporal/t.snap/t.snap.py @@ -40,10 +40,13 @@ def main(): name = options["input"] type = options["type"] - # Make sure the temporal database exists - tgis.init() + mapset = gs.gisenv()["MAPSET"] - dbif = tgis.SQLDatabaseInterfaceConnection() + # Try initializing the temporal database in the current mapset + tgis.init(skip_db_init=True) + dbif = tgis.SQLDatabaseInterfaceConnection(mapsets=mapset) + if not dbif.tgis_mapsets: + gs.fatal(_("No temporal database found in the current mapset.")) dbif.connect() stds = tgis.open_old_stds(name, type, dbif) diff --git a/temporal/t.support/t.support.py b/temporal/t.support/t.support.py index dc74e99f5e9..a815111242e 100755 --- a/temporal/t.support/t.support.py +++ b/temporal/t.support/t.support.py @@ -91,10 +91,13 @@ def main(): update = flags["u"] map_update = flags["m"] - # Make sure the temporal database exists - tgis.init() + mapset = gs.gisenv()["MAPSET"] - dbif = tgis.SQLDatabaseInterfaceConnection() + # Try initializing the temporal database in the current mapset + tgis.init(skip_db_init=True) + dbif = tgis.SQLDatabaseInterfaceConnection(mapsets=mapset) + if not dbif.tgis_mapsets: + gs.fatal(_("No temporal database found in the current mapset.")) dbif.connect() stds = tgis.open_old_stds(name, type, dbif) @@ -154,7 +157,7 @@ def main(): statement += map.delete(dbif=dbif, update=False, execute=False) # Execute the collected SQL statements - dbif.execute_transaction(statement) + dbif.execute_transaction(statement, mapsets=mapset) # Update the effected space time datasets for id in dataset_dict: diff --git a/temporal/t.upgrade/t.upgrade.py b/temporal/t.upgrade/t.upgrade.py index 94cef749808..8a9d0c7eb6c 100644 --- a/temporal/t.upgrade/t.upgrade.py +++ b/temporal/t.upgrade/t.upgrade.py @@ -25,9 +25,13 @@ def main(): # lazy imports import grass.temporal as tgis - tgis.init(skip_db_version_check=True) + mapset = gs.gisenv()["MAPSET"] + tgis.init(skip_db_init=True, skip_db_version_check=True) - dbif = tgis.SQLDatabaseInterfaceConnection() + dbif = tgis.SQLDatabaseInterfaceConnection(mapsets=mapset) + if not dbif.tgis_mapsets: + gs.message(_("No temporal database found in the current mapset.")) + return dbif.connect() tgis.upgrade_temporal_database(dbif) From 55d5aa14a47aa5400977c49c0aa8d575f82c8349 Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 21 Sep 2026 09:45:58 +0200 Subject: [PATCH 05/18] replace by pytest --- temporal/t.remove/test.t.remove.sh | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100755 temporal/t.remove/test.t.remove.sh diff --git a/temporal/t.remove/test.t.remove.sh b/temporal/t.remove/test.t.remove.sh deleted file mode 100755 index a674c8660c3..00000000000 --- a/temporal/t.remove/test.t.remove.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/sh -# We need to set a specific region in the -# @preprocess step of this test. We generate -# raster with r.mapcalc and create several space time raster datasets -# with absolute time -# The region setting should work for UTM and LL test locations -g.region s=0 n=80 w=0 e=120 b=0 t=50 res=10 res3=10 -p3 - -# Generate data -r.mapcalc --o expr="prec_1 = rand(0, 550)" -r.mapcalc --o expr="prec_2 = rand(0, 450)" -r.mapcalc --o expr="prec_3 = rand(0, 320)" -r.mapcalc --o expr="prec_4 = rand(0, 510)" -r.mapcalc --o expr="prec_5 = rand(0, 300)" -r.mapcalc --o expr="prec_6 = rand(0, 650)" - -# The first @test -t.create --o type=strds temporaltype=absolute output=precip_abs1 title="A test" descr="A test" -t.register --o -i input=precip_abs1 maps=prec_1,prec_2,prec_3,prec_4,prec_5,prec_6 start="2001-01-01" increment="1 seconds" -t.remove -rf type=strds input=precip_abs1 -# This will produce an error -t.remove -r type=strds input=precip_abs1 From 58f8b42ddc010f0854ced0a50196687b24d4f065 Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 21 Sep 2026 10:02:20 +0200 Subject: [PATCH 06/18] replace by pytest --- temporal/t.remove/tests/conftest.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 temporal/t.remove/tests/conftest.py diff --git a/temporal/t.remove/tests/conftest.py b/temporal/t.remove/tests/conftest.py new file mode 100644 index 00000000000..28b5efcbd28 --- /dev/null +++ b/temporal/t.remove/tests/conftest.py @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +"""Fixture for t.remove tests""" + +import os + +import pytest + +import grass.script as gs +from grass.tools import Tools + + +@pytest.fixture(scope="module") +def remove_session(tmp_path_factory): + """Active session in a project with the region set for small test rasters. + + Module-scoped: only the project and region setup are shared. Each test + creates its own uniquely named datasets and maps, so tests do not + interfere with each other despite sharing the session. + """ + tmp_path = tmp_path_factory.mktemp("t_remove") + project = tmp_path / "test" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + Tools(session=session).g_region(s=0, n=80, w=0, e=120, res=10) + yield session From 5d636db8568761781ed809b3944ec185a5022828 Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 21 Sep 2026 10:02:39 +0200 Subject: [PATCH 07/18] replace by pytest --- temporal/t.remove/tests/t_remove_test.py | 254 +++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 temporal/t.remove/tests/t_remove_test.py diff --git a/temporal/t.remove/tests/t_remove_test.py b/temporal/t.remove/tests/t_remove_test.py new file mode 100644 index 00000000000..c7b3d30c40a --- /dev/null +++ b/temporal/t.remove/tests/t_remove_test.py @@ -0,0 +1,254 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +"""Tests for t.remove + +Covers the preview (no force) case, the three levels of actual removal +(dataset only, dataset and unregistering, dataset and deleting the maps), +the cross-dataset effect of fully deleting a shared map, multiple datasets +given as a comma-separated list or a file, and the input validation rules. +""" + +import pytest + +from grass.tools import ToolError, Tools + + +def _create_strds(tools, name, maps=(), start="2001-01-01", increment="1 month"): + """Create an absolute-time STRDS, optionally registering new rasters in it.""" + tools.t_create( + type="strds", + temporaltype="absolute", + output=name, + title=name, + description=name, + ) + if maps: + tools.t_register( + type="raster", + input=name, + maps=",".join(maps), + start=start, + increment=increment, + flags="i", + ) + + +def _strds_names(tools): + """Return the names of all STRDS in the temporal database.""" + return [row["name"] for row in tools.t_list(type="strds", format="json").json] + + +def _raster_names_in_tgis_db(tools): + """Return the names of all raster maps registered in the temporal database.""" + return [row["name"] for row in tools.t_list(type="raster", format="json").json] + + +def _map_names(tools, type_): + """Return the names of all maps of the given type in the spatial database.""" + return [row["name"] for row in tools.g_list(type=type_, format="json").json] + + +def test_without_force_leaves_everything_unchanged(remove_session): + """Without -f the tool only previews the removal; nothing is changed.""" + tools = Tools(session=remove_session) + tools.r_mapcalc(expression="preview_1 = 1") + _create_strds(tools, "preview_strds", maps=["preview_1"]) + + tools.t_remove(type="strds", inputs="preview_strds") + + assert "preview_strds" in _strds_names(tools) + assert "preview_1" in _raster_names_in_tgis_db(tools) + assert "preview_1" in _map_names(tools, "raster") + + +def test_force_only_removes_dataset_but_keeps_maps(remove_session): + """-f alone removes the STDS entry but leaves the maps registered and in place.""" + tools = Tools(session=remove_session) + tools.r_mapcalc(expression="force_only_1 = 1") + _create_strds(tools, "force_only_strds", maps=["force_only_1"]) + + tools.t_remove(type="strds", inputs="force_only_strds", flags="f") + + assert "force_only_strds" not in _strds_names(tools) + assert "force_only_1" in _raster_names_in_tgis_db(tools) + assert "force_only_1" in _map_names(tools, "raster") + + +def test_recursive_force_unregisters_maps_but_keeps_files(remove_session): + """-rf removes the STDS and unregisters its maps, but keeps the map files.""" + tools = Tools(session=remove_session) + tools.r_mapcalc(expression="recursive_1 = 1") + _create_strds(tools, "recursive_strds", maps=["recursive_1"]) + + tools.t_remove(type="strds", inputs="recursive_strds", flags="rf") + + assert "recursive_strds" not in _strds_names(tools) + assert "recursive_1" not in _raster_names_in_tgis_db(tools) + assert "recursive_1" in _map_names(tools, "raster") + + +def test_delete_removes_the_raster_files_too(remove_session): + """-df removes the STDS, unregisters the maps and deletes the raster files.""" + tools = Tools(session=remove_session) + tools.r_mapcalc(expression="delete_1 = 1") + _create_strds(tools, "delete_strds", maps=["delete_1"]) + + tools.t_remove(type="strds", inputs="delete_strds", flags="df") + + assert "delete_strds" not in _strds_names(tools) + assert "delete_1" not in _raster_names_in_tgis_db(tools) + assert "delete_1" not in _map_names(tools, "raster") + + +def test_delete_removes_vector_files_for_stvds(remove_session): + """-df on a STVDS deletes the vector maps, exercising the vector g.remove path.""" + tools = Tools(session=remove_session) + tools.v_random(output="delete_vect_1", npoints=5, seed=1) + tools.t_create( + type="stvds", + temporaltype="absolute", + output="delete_vstrds", + title="delete_vstrds", + description="delete_vstrds", + ) + tools.t_register( + type="vector", + input="delete_vstrds", + maps="delete_vect_1", + start="2001-01-01", + increment="1 month", + flags="i", + ) + + tools.t_remove(type="stvds", inputs="delete_vstrds", flags="df") + + assert "delete_vstrds" not in [ + row["name"] for row in tools.t_list(type="stvds", format="json").json + ] + assert "delete_vect_1" not in _map_names(tools, "vector") + + +def test_recursive_force_on_shared_map_affects_other_dataset(remove_session): + """Fully deleting a map registered in several STDS removes it from all of them. + + t.remove -r deletes each of its input STDS's maps from the temporal + database entirely (not just from that one STDS), so a map shared with + another, untouched STDS disappears from there too. + """ + tools = Tools(session=remove_session) + tools.r_mapcalc(expression="shared_only_a = 1") + tools.r_mapcalc(expression="shared_map = 2") + tools.r_mapcalc(expression="shared_only_b = 3") + _create_strds(tools, "shared_strds_a", maps=["shared_only_a", "shared_map"]) + _create_strds(tools, "shared_strds_b", maps=["shared_only_b"]) + # shared_map already has a time stamp from strds_a, so it is registered + # in strds_b without another start/increment. + tools.t_register(type="raster", input="shared_strds_b", maps="shared_map") + + tools.t_remove(type="strds", inputs="shared_strds_a", flags="rf") + + assert "shared_strds_a" not in _strds_names(tools) + remaining = [ + row["name"] + for row in tools.t_rast_list( + input="shared_strds_b", columns="name", format="json" + ).json["data"] + ] + assert remaining == ["shared_only_b"] + + +def test_cannot_remove_foreign_mapset_stds_without_local_db(remove_session): + """A STDS in another mapset is left alone when the current mapset has no + temporal database at all: t.remove finds no database to query and quietly + does nothing, instead of falling back to some other mapset's database. + """ + tools = Tools(session=remove_session) + tools.r_mapcalc(expression="foreign_nodb_1 = 1") + _create_strds(tools, "foreign_nodb_strds", maps=["foreign_nodb_1"]) + + tools.g_mapset(mapset="foreign_nodb", flags="c") + try: + tools.t_remove(type="strds", inputs="foreign_nodb_strds@PERMANENT", flags="df") + finally: + tools.g_mapset(mapset="PERMANENT") + + assert "foreign_nodb_strds" in _strds_names(tools) + assert "foreign_nodb_1" in _map_names(tools, "raster") + + +def test_cannot_remove_foreign_mapset_stds_with_local_db(remove_session): + """A STDS in another mapset is left alone even when the current mapset + has its own temporal database: t.remove connects only to the current + mapset's database, so the STDS named as name@PERMANENT is not found + there and the removal fails instead of reaching into PERMANENT. + """ + tools = Tools(session=remove_session) + tools.r_mapcalc(expression="foreign_withdb_1 = 1") + _create_strds(tools, "foreign_withdb_strds", maps=["foreign_withdb_1"]) + + tools.g_mapset(mapset="foreign_withdb", flags="c") + try: + # Force a local temporal database to exist in this mapset. + _create_strds(tools, "local") + with pytest.raises(ToolError): + tools.t_remove( + type="strds", inputs="foreign_withdb_strds@PERMANENT", flags="df" + ) + finally: + tools.g_mapset(mapset="PERMANENT") + + assert "foreign_withdb_strds" in _strds_names(tools) + assert "foreign_withdb_1" in _map_names(tools, "raster") + + +def test_removing_an_already_removed_dataset_fails(remove_session): + """Removing a STDS that no longer exists reports an error instead of succeeding.""" + tools = Tools(session=remove_session) + tools.r_mapcalc(expression="twice_1 = 1") + _create_strds(tools, "twice_strds", maps=["twice_1"]) + + tools.t_remove(type="strds", inputs="twice_strds", flags="rf") + with pytest.raises(ToolError): + tools.t_remove(type="strds", inputs="twice_strds", flags="r") + + +def test_multiple_datasets_via_comma_separated_inputs(remove_session): + """A single call with a comma-separated list removes every named STDS.""" + tools = Tools(session=remove_session) + _create_strds(tools, "multi_1") + _create_strds(tools, "multi_2") + + tools.t_remove(type="strds", inputs="multi_1,multi_2", flags="f") + + names = _strds_names(tools) + assert "multi_1" not in names + assert "multi_2" not in names + + +def test_datasets_via_file_option(remove_session, tmp_path): + """The file option accepts dataset names listed one per line.""" + tools = Tools(session=remove_session) + _create_strds(tools, "file_listed_1") + _create_strds(tools, "file_listed_2") + dataset_file = tmp_path / "names.txt" + dataset_file.write_text("file_listed_1\nfile_listed_2\n") + + tools.t_remove(type="strds", file=dataset_file, flags="f") + + names = _strds_names(tools) + assert "file_listed_1" not in names + assert "file_listed_2" not in names + + +def test_inputs_and_file_are_mutually_exclusive(remove_session, tmp_path): + """inputs and file cannot be given together; the STDS is left untouched.""" + tools = Tools(session=remove_session) + _create_strds(tools, "exclusive_ds") + dataset_file = tmp_path / "names.txt" + dataset_file.write_text("exclusive_ds\n") + + with pytest.raises(ToolError): + tools.t_remove( + type="strds", inputs="exclusive_ds", file=dataset_file, flags="f" + ) + + assert "exclusive_ds" in _strds_names(tools) From 109209b306839d7b0488f8dc03d71a187cbae51c Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 21 Sep 2026 10:07:45 +0200 Subject: [PATCH 08/18] workaround using fork also with Python 3.14 --- python/grass/pygrass/messages/__init__.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/python/grass/pygrass/messages/__init__.py b/python/grass/pygrass/messages/__init__.py index 2d06c2c9a31..cf20e259e43 100644 --- a/python/grass/pygrass/messages/__init__.py +++ b/python/grass/pygrass/messages/__init__.py @@ -15,13 +15,15 @@ from __future__ import annotations import sys -from multiprocessing import Lock, Pipe, Process +from multiprocessing import Lock, Pipe from typing import TYPE_CHECKING, Literal, NoReturn import grass.lib.gis as libgis from grass.exceptions import FatalError +from grass.script.utils import get_fork_context if TYPE_CHECKING: + from multiprocessing import Process from multiprocessing.connection import Connection from multiprocessing.context import _LockLike @@ -181,7 +183,9 @@ def __init__(self, raise_on_error: bool = False) -> None: self.raise_on_error = raise_on_error self.client_conn, self.server_conn = Pipe() self.lock = Lock() - self.server = Process(target=message_server, args=(self.lock, self.server_conn)) + self.server = get_fork_context().Process( + target=message_server, args=(self.lock, self.server_conn) + ) self.server.daemon = True self.server.start() @@ -189,7 +193,9 @@ def start_server(self) -> None: """Start the messenger server and open the pipe""" self.client_conn, self.server_conn = Pipe() self.lock = Lock() - self.server = Process(target=message_server, args=(self.lock, self.server_conn)) + self.server = get_fork_context().Process( + target=message_server, args=(self.lock, self.server_conn) + ) self.server.daemon = True self.server.start() From d3326ca2671f0e32fc74b668d483e9aa4059a850 Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 21 Sep 2026 10:08:15 +0200 Subject: [PATCH 09/18] workaround using fork also with Python 3.14 --- python/grass/pygrass/modules/interface/module.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/grass/pygrass/modules/interface/module.py b/python/grass/pygrass/modules/interface/module.py index def9638217e..488daeb41a8 100644 --- a/python/grass/pygrass/modules/interface/module.py +++ b/python/grass/pygrass/modules/interface/module.py @@ -912,7 +912,7 @@ class MultiModule: >>> from grass.pygrass.modules import Module >>> from grass.pygrass.modules import MultiModule - >>> from multiprocessing import Process + >>> from multiprocessing.process import BaseProcess >>> import copy Synchronous module run @@ -950,7 +950,7 @@ class MultiModule: ... sync=False, ... ) >>> t = mm.run() - >>> isinstance(t, Process) + >>> isinstance(t, BaseProcess) True >>> m_list = mm.wait() >>> m_list[0].returncode @@ -976,7 +976,7 @@ class MultiModule: >>> str(mm) 'g.region format=plain -p ; g.region format=plain -p ; g.region format=plain -p ; g.region format=plain -p ; g.region format=plain -p' >>> t = mm.run() - >>> isinstance(t, Process) + >>> isinstance(t, BaseProcess) True >>> m_list = mm.wait() >>> m_list[0].returncode From e4a20154629d628dc9b369114b7b0fb33a8122a5 Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 21 Sep 2026 10:10:00 +0200 Subject: [PATCH 10/18] workaround using fork also with Python 3.14 --- python/grass/pygrass/rpc/base.py | 7 +++-- python/grass/script/__init__.py | 2 ++ python/grass/script/utils.py | 28 +++++++++++++++++++ python/grass/temporal/abstract_dataset.py | 1 - .../grass/temporal/c_libraries_interface.py | 4 +-- python/grass/temporal/core.py | 1 - python/grass/temporal/mapcalc.py | 10 +++++-- 7 files changed, 44 insertions(+), 9 deletions(-) diff --git a/python/grass/pygrass/rpc/base.py b/python/grass/pygrass/rpc/base.py index 964d2a22b04..163b9f89288 100644 --- a/python/grass/pygrass/rpc/base.py +++ b/python/grass/pygrass/rpc/base.py @@ -14,10 +14,11 @@ import sys import threading import time -from multiprocessing import Lock, Pipe, Process +from multiprocessing import Lock, Pipe from typing import TYPE_CHECKING, NoReturn from grass.exceptions import FatalError +from grass.script.utils import get_fork_context if TYPE_CHECKING: from multiprocessing.connection import Connection @@ -142,7 +143,9 @@ def start_server(self): self.client_conn, self.server_conn = Pipe(True) self.lock = Lock() - self.server = Process(target=dummy_server, args=(self.lock, self.server_conn)) + self.server = get_fork_context().Process( + target=dummy_server, args=(self.lock, self.server_conn) + ) self.server.daemon = True self.server.start() diff --git a/python/grass/script/__init__.py b/python/grass/script/__init__.py index d7009380347..53b3a0b32c6 100644 --- a/python/grass/script/__init__.py +++ b/python/grass/script/__init__.py @@ -93,6 +93,7 @@ diff_files, encode, float_or_dms, + get_fork_context, get_lib_path, get_num_suffix, legalize_vector_name, @@ -159,6 +160,7 @@ "float_or_dms", "get_capture_stderr", "get_commands", + "get_fork_context", "get_lib_path", "get_num_suffix", "get_raise_on_error", diff --git a/python/grass/script/utils.py b/python/grass/script/utils.py index c9e915a0de6..142d88864b5 100644 --- a/python/grass/script/utils.py +++ b/python/grass/script/utils.py @@ -125,6 +125,34 @@ def resolve_nprocs(nprocs: int | str) -> int: return max(1, available + n) +def get_fork_context(): + """Return a :mod:`multiprocessing` context using the ``fork`` start method. + + Several GRASS Python APIs start worker processes that are expected to + inherit the exact environment and GRASS session state (for example + ``GISRC``/current mapset set by :func:`grass.script.setup.init`) of + their parent at the time they are started. That assumption only holds + for the ``fork`` start method, where the child is a copy of the parent + process. It does not hold for ``spawn``, and, notably, not for + ``forkserver`` either: its workers are forked from a long-lived helper + process instead of the caller, and so only see the environment that + existed when that helper process itself was started, which can be + stale. Python 3.14 made ``forkserver`` the default start method on Linux + (previously ``fork``), so callers that rely on this inheritance must + request ``fork`` explicitly. + + ``fork`` is unavailable on Windows, which has always defaulted to + ``spawn``; the default context is returned there instead. + + .. versionadded:: 8.6 + """ + import multiprocessing + + if "fork" in multiprocessing.get_all_start_methods(): + return multiprocessing.get_context("fork") + return multiprocessing.get_context() + + def diff_files( filename_a: FileDescriptorOrPath, filename_b: FileDescriptorOrPath ) -> list[str]: diff --git a/python/grass/temporal/abstract_dataset.py b/python/grass/temporal/abstract_dataset.py index a22e99d3621..979cda3f319 100644 --- a/python/grass/temporal/abstract_dataset.py +++ b/python/grass/temporal/abstract_dataset.py @@ -457,7 +457,6 @@ def update(self, dbif=None, execute: bool = True, ident=None): if self.is_stds() is False: statement += self.stds_register.get_update_statement_mogrified(dbif, ident) - if execute: dbif.execute_transaction(statement) if connection_state_changed: diff --git a/python/grass/temporal/c_libraries_interface.py b/python/grass/temporal/c_libraries_interface.py index ceb0eea72ab..65b5879910c 100644 --- a/python/grass/temporal/c_libraries_interface.py +++ b/python/grass/temporal/c_libraries_interface.py @@ -14,7 +14,7 @@ import sys from ctypes import CFUNCTYPE, POINTER, byref, c_int, c_void_p, cast from datetime import datetime -from multiprocessing import Lock, Pipe, Process +from multiprocessing import Lock, Pipe from typing import TYPE_CHECKING, Any, Literal import grass.lib.date as libdate @@ -1471,7 +1471,7 @@ def __init__(self) -> None: def start_server(self) -> None: self.client_conn, self.server_conn = Pipe(True) self.lock = Lock() - self.server = Process( + self.server = gs.get_fork_context().Process( target=c_library_server, args=(self.lock, self.server_conn) ) self.server.daemon = True diff --git a/python/grass/temporal/core.py b/python/grass/temporal/core.py index 4ac181a3f81..2391c07765e 100644 --- a/python/grass/temporal/core.py +++ b/python/grass/temporal/core.py @@ -1262,7 +1262,6 @@ def execute(self, statement, args=None, mapset=None): + self._create_mapset_error_message(mapset) ) ) - return self.connections[mapset].execute(statement, args) def fetchone(self, mapset=None): diff --git a/python/grass/temporal/mapcalc.py b/python/grass/temporal/mapcalc.py index ed3ca9cbfef..d3695e92d64 100644 --- a/python/grass/temporal/mapcalc.py +++ b/python/grass/temporal/mapcalc.py @@ -10,7 +10,6 @@ import copy import sys from datetime import datetime -from multiprocessing import Process import grass.script as gs from grass.exceptions import CalledModuleError @@ -234,6 +233,7 @@ def dataset_mapcalculator( num = len(map_matrix[0]) # Parallel processing + mp_context = gs.get_fork_context() proc_list = [] proc_count = 0 @@ -309,9 +309,13 @@ def dataset_mapcalculator( # Start the parallel r.mapcalc computation if type == "raster": - proc_list.append(Process(target=_run_mapcalc2d, args=(expr,))) + proc_list.append( + mp_context.Process(target=_run_mapcalc2d, args=(expr,)) + ) else: - proc_list.append(Process(target=_run_mapcalc3d, args=(expr,))) + proc_list.append( + mp_context.Process(target=_run_mapcalc3d, args=(expr,)) + ) proc_list[proc_count].start() proc_count += 1 From 11ef6aea30f4ecb4907ce6a01aca051de2d00a48 Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 21 Sep 2026 10:10:41 +0200 Subject: [PATCH 11/18] workaround using fork also with Python 3.14 --- python/grass/temporal/extract.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python/grass/temporal/extract.py b/python/grass/temporal/extract.py index 2d7bff85c54..dbfedf6c33e 100644 --- a/python/grass/temporal/extract.py +++ b/python/grass/temporal/extract.py @@ -10,7 +10,6 @@ import re import sys -from multiprocessing import Process import grass.script as gs from grass.exceptions import CalledModuleError @@ -207,6 +206,7 @@ def extract_dataset( # Run the mapcalc expression if expression: + mp_context = gs.get_fork_context() proc_count = 0 proc_list = [] @@ -262,15 +262,15 @@ def extract_dataset( # Add process to the process list if type == "raster": msgr.verbose(_('Applying r.mapcalc expression: "%s"') % expr) - proc_list.append(Process(target=run_mapcalc2d, args=(expr,))) + proc_list.append(mp_context.Process(target=run_mapcalc2d, args=(expr,))) elif type == "raster3d": msgr.verbose(_('Applying r3.mapcalc expression: "%s"') % expr) - proc_list.append(Process(target=run_mapcalc3d, args=(expr,))) + proc_list.append(mp_context.Process(target=run_mapcalc3d, args=(expr,))) elif type == "vector": msgr.verbose(_('Applying v.extract where statement: "%s"') % expression) if row["layer"]: proc_list.append( - Process( + mp_context.Process( target=run_vector_extraction, args=( row["name"] + "@" + row["mapset"], @@ -283,7 +283,7 @@ def extract_dataset( ) else: proc_list.append( - Process( + mp_context.Process( target=run_vector_extraction, args=( row["name"] + "@" + row["mapset"], From f0c191062d09c697782f3f90b3d77281480a2bee Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 21 Sep 2026 12:11:08 +0200 Subject: [PATCH 12/18] restore --- temporal/t.remove/t.remove.py | 36 ++-- temporal/t.remove/test.t.remove.sh | 22 ++ temporal/t.remove/tests/conftest.py | 25 --- temporal/t.remove/tests/t_remove_test.py | 254 ----------------------- temporal/t.rename/t.rename.py | 39 ++-- temporal/t.shift/t.shift.py | 9 +- temporal/t.snap/t.snap.py | 9 +- temporal/t.support/t.support.py | 11 +- temporal/t.upgrade/t.upgrade.py | 8 +- 9 files changed, 67 insertions(+), 346 deletions(-) create mode 100755 temporal/t.remove/test.t.remove.sh delete mode 100644 temporal/t.remove/tests/conftest.py delete mode 100644 temporal/t.remove/tests/t_remove_test.py diff --git a/temporal/t.remove/t.remove.py b/temporal/t.remove/t.remove.py index 314cd7319a7..b9128c12bd5 100755 --- a/temporal/t.remove/t.remove.py +++ b/temporal/t.remove/t.remove.py @@ -57,10 +57,6 @@ # % description: Remove stds, unregister maps from temporal database and delete them from mapset # %end -# %rules -# % exclusive: inputs, file -# %end - import grass.script as gs # lazy imports at the end of the file @@ -77,28 +73,23 @@ def main(): force = flags["f"] clean = flags["d"] - mapset = gs.gisenv()["MAPSET"] + if datasets and file: + gs.fatal(_("%s= and %s= are mutually exclusive") % ("input", "file")) - # Initialize TGIS, the temporal database has to exist already - tgis.init(skip_db_init=True) + # Make sure the temporal database exists + tgis.init() - dbif = tgis.SQLDatabaseInterfaceConnection(mapsets=mapset) - - # No TGIS DB found in the current mapset - if not dbif.tgis_mapsets: - gs.message( - _( - "No temporal database found in the requested mapset(s). No datasets to list." - ) - ) - return + dbif = tgis.SQLDatabaseInterfaceConnection() dbif.connect() dataset_list = [] # Dataset names as comma separated string if datasets: - dataset_list = datasets.split(",") + if datasets.find(",") == -1: + dataset_list = (datasets,) + else: + dataset_list = tuple(datasets.split(",")) if file: line = True @@ -141,14 +132,15 @@ def main(): "from spatial database:" ) - gs.message(msg.format(stds=sp.get_type(), gid=sp.get_id())) + if recursive or clean: + gs.message(msg.format(stds=sp.get_type(), gid=sp.get_id())) maps = sp.get_registered_maps_as_objects(dbif=dbif) map_statement = "" count = 1 name_list = [] for map in maps: - map.select(dbif, mapset=mapset) + map.select(dbif) # We may have multiple layer for a single map, hence we need # to avoid multiple deletions of the same map, # but the database entries are still present and must be removed @@ -175,7 +167,7 @@ def main(): name_list = [] if map_statement: - dbif.execute_transaction(map_statement, mapset=mapset) + dbif.execute_transaction(map_statement) if clean and name_list: if type == "strds": remove(type="raster", name=name_list, run_=True) @@ -195,7 +187,7 @@ def main(): ) else: # Execute the collected SQL statenents - dbif.execute_transaction(statement, mapset=mapset) + dbif.execute_transaction(statement) dbif.close() diff --git a/temporal/t.remove/test.t.remove.sh b/temporal/t.remove/test.t.remove.sh new file mode 100755 index 00000000000..a674c8660c3 --- /dev/null +++ b/temporal/t.remove/test.t.remove.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# We need to set a specific region in the +# @preprocess step of this test. We generate +# raster with r.mapcalc and create several space time raster datasets +# with absolute time +# The region setting should work for UTM and LL test locations +g.region s=0 n=80 w=0 e=120 b=0 t=50 res=10 res3=10 -p3 + +# Generate data +r.mapcalc --o expr="prec_1 = rand(0, 550)" +r.mapcalc --o expr="prec_2 = rand(0, 450)" +r.mapcalc --o expr="prec_3 = rand(0, 320)" +r.mapcalc --o expr="prec_4 = rand(0, 510)" +r.mapcalc --o expr="prec_5 = rand(0, 300)" +r.mapcalc --o expr="prec_6 = rand(0, 650)" + +# The first @test +t.create --o type=strds temporaltype=absolute output=precip_abs1 title="A test" descr="A test" +t.register --o -i input=precip_abs1 maps=prec_1,prec_2,prec_3,prec_4,prec_5,prec_6 start="2001-01-01" increment="1 seconds" +t.remove -rf type=strds input=precip_abs1 +# This will produce an error +t.remove -r type=strds input=precip_abs1 diff --git a/temporal/t.remove/tests/conftest.py b/temporal/t.remove/tests/conftest.py deleted file mode 100644 index 28b5efcbd28..00000000000 --- a/temporal/t.remove/tests/conftest.py +++ /dev/null @@ -1,25 +0,0 @@ -# SPDX-License-Identifier: GPL-2.0-or-later -"""Fixture for t.remove tests""" - -import os - -import pytest - -import grass.script as gs -from grass.tools import Tools - - -@pytest.fixture(scope="module") -def remove_session(tmp_path_factory): - """Active session in a project with the region set for small test rasters. - - Module-scoped: only the project and region setup are shared. Each test - creates its own uniquely named datasets and maps, so tests do not - interfere with each other despite sharing the session. - """ - tmp_path = tmp_path_factory.mktemp("t_remove") - project = tmp_path / "test" - gs.create_project(project) - with gs.setup.init(project, env=os.environ.copy()) as session: - Tools(session=session).g_region(s=0, n=80, w=0, e=120, res=10) - yield session diff --git a/temporal/t.remove/tests/t_remove_test.py b/temporal/t.remove/tests/t_remove_test.py deleted file mode 100644 index c7b3d30c40a..00000000000 --- a/temporal/t.remove/tests/t_remove_test.py +++ /dev/null @@ -1,254 +0,0 @@ -# SPDX-License-Identifier: GPL-2.0-or-later -"""Tests for t.remove - -Covers the preview (no force) case, the three levels of actual removal -(dataset only, dataset and unregistering, dataset and deleting the maps), -the cross-dataset effect of fully deleting a shared map, multiple datasets -given as a comma-separated list or a file, and the input validation rules. -""" - -import pytest - -from grass.tools import ToolError, Tools - - -def _create_strds(tools, name, maps=(), start="2001-01-01", increment="1 month"): - """Create an absolute-time STRDS, optionally registering new rasters in it.""" - tools.t_create( - type="strds", - temporaltype="absolute", - output=name, - title=name, - description=name, - ) - if maps: - tools.t_register( - type="raster", - input=name, - maps=",".join(maps), - start=start, - increment=increment, - flags="i", - ) - - -def _strds_names(tools): - """Return the names of all STRDS in the temporal database.""" - return [row["name"] for row in tools.t_list(type="strds", format="json").json] - - -def _raster_names_in_tgis_db(tools): - """Return the names of all raster maps registered in the temporal database.""" - return [row["name"] for row in tools.t_list(type="raster", format="json").json] - - -def _map_names(tools, type_): - """Return the names of all maps of the given type in the spatial database.""" - return [row["name"] for row in tools.g_list(type=type_, format="json").json] - - -def test_without_force_leaves_everything_unchanged(remove_session): - """Without -f the tool only previews the removal; nothing is changed.""" - tools = Tools(session=remove_session) - tools.r_mapcalc(expression="preview_1 = 1") - _create_strds(tools, "preview_strds", maps=["preview_1"]) - - tools.t_remove(type="strds", inputs="preview_strds") - - assert "preview_strds" in _strds_names(tools) - assert "preview_1" in _raster_names_in_tgis_db(tools) - assert "preview_1" in _map_names(tools, "raster") - - -def test_force_only_removes_dataset_but_keeps_maps(remove_session): - """-f alone removes the STDS entry but leaves the maps registered and in place.""" - tools = Tools(session=remove_session) - tools.r_mapcalc(expression="force_only_1 = 1") - _create_strds(tools, "force_only_strds", maps=["force_only_1"]) - - tools.t_remove(type="strds", inputs="force_only_strds", flags="f") - - assert "force_only_strds" not in _strds_names(tools) - assert "force_only_1" in _raster_names_in_tgis_db(tools) - assert "force_only_1" in _map_names(tools, "raster") - - -def test_recursive_force_unregisters_maps_but_keeps_files(remove_session): - """-rf removes the STDS and unregisters its maps, but keeps the map files.""" - tools = Tools(session=remove_session) - tools.r_mapcalc(expression="recursive_1 = 1") - _create_strds(tools, "recursive_strds", maps=["recursive_1"]) - - tools.t_remove(type="strds", inputs="recursive_strds", flags="rf") - - assert "recursive_strds" not in _strds_names(tools) - assert "recursive_1" not in _raster_names_in_tgis_db(tools) - assert "recursive_1" in _map_names(tools, "raster") - - -def test_delete_removes_the_raster_files_too(remove_session): - """-df removes the STDS, unregisters the maps and deletes the raster files.""" - tools = Tools(session=remove_session) - tools.r_mapcalc(expression="delete_1 = 1") - _create_strds(tools, "delete_strds", maps=["delete_1"]) - - tools.t_remove(type="strds", inputs="delete_strds", flags="df") - - assert "delete_strds" not in _strds_names(tools) - assert "delete_1" not in _raster_names_in_tgis_db(tools) - assert "delete_1" not in _map_names(tools, "raster") - - -def test_delete_removes_vector_files_for_stvds(remove_session): - """-df on a STVDS deletes the vector maps, exercising the vector g.remove path.""" - tools = Tools(session=remove_session) - tools.v_random(output="delete_vect_1", npoints=5, seed=1) - tools.t_create( - type="stvds", - temporaltype="absolute", - output="delete_vstrds", - title="delete_vstrds", - description="delete_vstrds", - ) - tools.t_register( - type="vector", - input="delete_vstrds", - maps="delete_vect_1", - start="2001-01-01", - increment="1 month", - flags="i", - ) - - tools.t_remove(type="stvds", inputs="delete_vstrds", flags="df") - - assert "delete_vstrds" not in [ - row["name"] for row in tools.t_list(type="stvds", format="json").json - ] - assert "delete_vect_1" not in _map_names(tools, "vector") - - -def test_recursive_force_on_shared_map_affects_other_dataset(remove_session): - """Fully deleting a map registered in several STDS removes it from all of them. - - t.remove -r deletes each of its input STDS's maps from the temporal - database entirely (not just from that one STDS), so a map shared with - another, untouched STDS disappears from there too. - """ - tools = Tools(session=remove_session) - tools.r_mapcalc(expression="shared_only_a = 1") - tools.r_mapcalc(expression="shared_map = 2") - tools.r_mapcalc(expression="shared_only_b = 3") - _create_strds(tools, "shared_strds_a", maps=["shared_only_a", "shared_map"]) - _create_strds(tools, "shared_strds_b", maps=["shared_only_b"]) - # shared_map already has a time stamp from strds_a, so it is registered - # in strds_b without another start/increment. - tools.t_register(type="raster", input="shared_strds_b", maps="shared_map") - - tools.t_remove(type="strds", inputs="shared_strds_a", flags="rf") - - assert "shared_strds_a" not in _strds_names(tools) - remaining = [ - row["name"] - for row in tools.t_rast_list( - input="shared_strds_b", columns="name", format="json" - ).json["data"] - ] - assert remaining == ["shared_only_b"] - - -def test_cannot_remove_foreign_mapset_stds_without_local_db(remove_session): - """A STDS in another mapset is left alone when the current mapset has no - temporal database at all: t.remove finds no database to query and quietly - does nothing, instead of falling back to some other mapset's database. - """ - tools = Tools(session=remove_session) - tools.r_mapcalc(expression="foreign_nodb_1 = 1") - _create_strds(tools, "foreign_nodb_strds", maps=["foreign_nodb_1"]) - - tools.g_mapset(mapset="foreign_nodb", flags="c") - try: - tools.t_remove(type="strds", inputs="foreign_nodb_strds@PERMANENT", flags="df") - finally: - tools.g_mapset(mapset="PERMANENT") - - assert "foreign_nodb_strds" in _strds_names(tools) - assert "foreign_nodb_1" in _map_names(tools, "raster") - - -def test_cannot_remove_foreign_mapset_stds_with_local_db(remove_session): - """A STDS in another mapset is left alone even when the current mapset - has its own temporal database: t.remove connects only to the current - mapset's database, so the STDS named as name@PERMANENT is not found - there and the removal fails instead of reaching into PERMANENT. - """ - tools = Tools(session=remove_session) - tools.r_mapcalc(expression="foreign_withdb_1 = 1") - _create_strds(tools, "foreign_withdb_strds", maps=["foreign_withdb_1"]) - - tools.g_mapset(mapset="foreign_withdb", flags="c") - try: - # Force a local temporal database to exist in this mapset. - _create_strds(tools, "local") - with pytest.raises(ToolError): - tools.t_remove( - type="strds", inputs="foreign_withdb_strds@PERMANENT", flags="df" - ) - finally: - tools.g_mapset(mapset="PERMANENT") - - assert "foreign_withdb_strds" in _strds_names(tools) - assert "foreign_withdb_1" in _map_names(tools, "raster") - - -def test_removing_an_already_removed_dataset_fails(remove_session): - """Removing a STDS that no longer exists reports an error instead of succeeding.""" - tools = Tools(session=remove_session) - tools.r_mapcalc(expression="twice_1 = 1") - _create_strds(tools, "twice_strds", maps=["twice_1"]) - - tools.t_remove(type="strds", inputs="twice_strds", flags="rf") - with pytest.raises(ToolError): - tools.t_remove(type="strds", inputs="twice_strds", flags="r") - - -def test_multiple_datasets_via_comma_separated_inputs(remove_session): - """A single call with a comma-separated list removes every named STDS.""" - tools = Tools(session=remove_session) - _create_strds(tools, "multi_1") - _create_strds(tools, "multi_2") - - tools.t_remove(type="strds", inputs="multi_1,multi_2", flags="f") - - names = _strds_names(tools) - assert "multi_1" not in names - assert "multi_2" not in names - - -def test_datasets_via_file_option(remove_session, tmp_path): - """The file option accepts dataset names listed one per line.""" - tools = Tools(session=remove_session) - _create_strds(tools, "file_listed_1") - _create_strds(tools, "file_listed_2") - dataset_file = tmp_path / "names.txt" - dataset_file.write_text("file_listed_1\nfile_listed_2\n") - - tools.t_remove(type="strds", file=dataset_file, flags="f") - - names = _strds_names(tools) - assert "file_listed_1" not in names - assert "file_listed_2" not in names - - -def test_inputs_and_file_are_mutually_exclusive(remove_session, tmp_path): - """inputs and file cannot be given together; the STDS is left untouched.""" - tools = Tools(session=remove_session) - _create_strds(tools, "exclusive_ds") - dataset_file = tmp_path / "names.txt" - dataset_file.write_text("exclusive_ds\n") - - with pytest.raises(ToolError): - tools.t_remove( - type="strds", inputs="exclusive_ds", file=dataset_file, flags="f" - ) - - assert "exclusive_ds" in _strds_names(tools) diff --git a/temporal/t.rename/t.rename.py b/temporal/t.rename/t.rename.py index 67906d5a9d7..a6d8d33be77 100755 --- a/temporal/t.rename/t.rename.py +++ b/temporal/t.rename/t.rename.py @@ -6,7 +6,7 @@ # AUTHOR(S): Soeren Gebbert # # PURPOSE: Renames a space time dataset -# SPDX-FileCopyrightText: 2011-2026 GRASS Development Team +# SPDX-FileCopyrightText: 2011-2017 GRASS Development Team # SPDX-License-Identifier: GPL-2.0-or-later # ############################################################################# @@ -44,21 +44,20 @@ def main(): output = options["output"] type = options["type"] + # Make sure the temporal database exists + tgis.init() + # Get the current mapset to create the id of the space time dataset mapset = gs.gisenv()["MAPSET"] - old_id = input if "@" in input else f"{input}@{mapset}" - new_id = output if "@" in output else f"{output}@{mapset}" + old_id = input if input.find("@") >= 0 else input + "@" + mapset + new_id = output if output.find("@") >= 0 else output + "@" + mapset # Do not overwrite yourself if new_id == old_id: return - # Try initializing the temporal database in the current mapset - tgis.init(skip_db_init=True) - dbif = tgis.SQLDatabaseInterfaceConnection(mapsets=mapset) - if not dbif.tgis_mapsets: - gs.fatal(_("No temporal database found in the current mapset.")) + dbif = tgis.SQLDatabaseInterfaceConnection() dbif.connect() stds = tgis.dataset_factory(type, old_id) @@ -73,7 +72,7 @@ def main(): % (stds.get_new_map_instance(None).get_type(), old_id) ) - if not stds.is_in_db(dbif=dbif, mapset=mapset): + if not stds.is_in_db(dbif=dbif): dbif.close() gs.fatal( _("Space time %s dataset <%s> not found") @@ -83,21 +82,21 @@ def main(): # Check if the new id is in the database new_stds = tgis.dataset_factory(type, new_id) - if new_stds.is_in_db(dbif=dbif, mapset=mapset): - if not gs.overwrite(): - dbif.close() - gs.fatal( - _( - "Unable to rename Space time %s dataset <%s>. Name <%s> " - "is in use, please use the overwrite flag." - ) - % (stds.get_new_map_instance(None).get_type(), old_id, new_id) + if new_stds.is_in_db(dbif=dbif) and not gs.overwrite(): + dbif.close() + gs.fatal( + _( + "Unable to rename Space time %s dataset <%s>. Name <%s> " + "is in use, please use the overwrite flag." ) + % (stds.get_new_map_instance(None).get_type(), old_id, new_id) + ) - # Remove an already existing space time dataset + # Remove an already existing space time dataset + if new_stds.is_in_db(dbif=dbif): new_stds.delete(dbif=dbif) - stds.select(dbif=dbif, mapset=mapset) + stds.select(dbif=dbif) stds.rename(ident=new_id, dbif=dbif) stds.update_command_string(dbif=dbif) diff --git a/temporal/t.shift/t.shift.py b/temporal/t.shift/t.shift.py index d82803c81a3..5fea088848b 100755 --- a/temporal/t.shift/t.shift.py +++ b/temporal/t.shift/t.shift.py @@ -50,13 +50,10 @@ def main(): type = options["type"] gran = options["granularity"] - mapset = gs.gisenv()["MAPSET"] + # Make sure the temporal database exists + tgis.init() - # Try initializing the temporal database in the current mapset - tgis.init(skip_db_init=True) - dbif = tgis.SQLDatabaseInterfaceConnection(mapsets=mapset) - if not dbif.tgis_mapsets: - gs.fatal(_("No temporal database found in the current mapset.")) + dbif = tgis.SQLDatabaseInterfaceConnection() dbif.connect() stds = tgis.open_old_stds(name, type, dbif) diff --git a/temporal/t.snap/t.snap.py b/temporal/t.snap/t.snap.py index 4032a1af823..4b8583376a5 100755 --- a/temporal/t.snap/t.snap.py +++ b/temporal/t.snap/t.snap.py @@ -40,13 +40,10 @@ def main(): name = options["input"] type = options["type"] - mapset = gs.gisenv()["MAPSET"] + # Make sure the temporal database exists + tgis.init() - # Try initializing the temporal database in the current mapset - tgis.init(skip_db_init=True) - dbif = tgis.SQLDatabaseInterfaceConnection(mapsets=mapset) - if not dbif.tgis_mapsets: - gs.fatal(_("No temporal database found in the current mapset.")) + dbif = tgis.SQLDatabaseInterfaceConnection() dbif.connect() stds = tgis.open_old_stds(name, type, dbif) diff --git a/temporal/t.support/t.support.py b/temporal/t.support/t.support.py index a815111242e..dc74e99f5e9 100755 --- a/temporal/t.support/t.support.py +++ b/temporal/t.support/t.support.py @@ -91,13 +91,10 @@ def main(): update = flags["u"] map_update = flags["m"] - mapset = gs.gisenv()["MAPSET"] + # Make sure the temporal database exists + tgis.init() - # Try initializing the temporal database in the current mapset - tgis.init(skip_db_init=True) - dbif = tgis.SQLDatabaseInterfaceConnection(mapsets=mapset) - if not dbif.tgis_mapsets: - gs.fatal(_("No temporal database found in the current mapset.")) + dbif = tgis.SQLDatabaseInterfaceConnection() dbif.connect() stds = tgis.open_old_stds(name, type, dbif) @@ -157,7 +154,7 @@ def main(): statement += map.delete(dbif=dbif, update=False, execute=False) # Execute the collected SQL statements - dbif.execute_transaction(statement, mapsets=mapset) + dbif.execute_transaction(statement) # Update the effected space time datasets for id in dataset_dict: diff --git a/temporal/t.upgrade/t.upgrade.py b/temporal/t.upgrade/t.upgrade.py index 8a9d0c7eb6c..94cef749808 100644 --- a/temporal/t.upgrade/t.upgrade.py +++ b/temporal/t.upgrade/t.upgrade.py @@ -25,13 +25,9 @@ def main(): # lazy imports import grass.temporal as tgis - mapset = gs.gisenv()["MAPSET"] - tgis.init(skip_db_init=True, skip_db_version_check=True) + tgis.init(skip_db_version_check=True) - dbif = tgis.SQLDatabaseInterfaceConnection(mapsets=mapset) - if not dbif.tgis_mapsets: - gs.message(_("No temporal database found in the current mapset.")) - return + dbif = tgis.SQLDatabaseInterfaceConnection() dbif.connect() tgis.upgrade_temporal_database(dbif) From 8eebb22f2713285b1969222010ef0da0a9fd564e Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 21 Sep 2026 12:14:11 +0200 Subject: [PATCH 13/18] restore --- python/grass/temporal/abstract_dataset.py | 1 + python/grass/temporal/base.py | 9 +++++++++ python/grass/temporal/core.py | 1 + 3 files changed, 11 insertions(+) diff --git a/python/grass/temporal/abstract_dataset.py b/python/grass/temporal/abstract_dataset.py index 979cda3f319..a22e99d3621 100644 --- a/python/grass/temporal/abstract_dataset.py +++ b/python/grass/temporal/abstract_dataset.py @@ -457,6 +457,7 @@ def update(self, dbif=None, execute: bool = True, ident=None): if self.is_stds() is False: statement += self.stds_register.get_update_statement_mogrified(dbif, ident) + if execute: dbif.execute_transaction(statement) if connection_state_changed: diff --git a/python/grass/temporal/base.py b/python/grass/temporal/base.py index 54eb06f39ce..3504844999f 100644 --- a/python/grass/temporal/base.py +++ b/python/grass/temporal/base.py @@ -289,6 +289,7 @@ def delete(self, dbif=None) -> None: if None a temporary connection will be established """ sql = self.get_delete_statement() + # print(sql) # must use the temporal database of the current mapset, # also if the map to be deleted is in a different mapset @@ -378,6 +379,8 @@ def select(self, dbif=None, mapset=None) -> bool: if None a temporary connection will be established """ sql, args = self.get_select_statement() + # print(sql) + # print(args) # default: use the temporal database in the mapset of this map if mapset is None: @@ -443,6 +446,8 @@ def insert(self, dbif=None) -> None: if None a temporary connection will be established """ sql, args = self.get_insert_statement() + # print(sql) + # print(args) # use the temporal database in the current mapset mapset = get_current_mapset() @@ -506,6 +511,8 @@ def update(self, dbif=None, ident=None) -> None: mapset = get_current_mapset() sql, args = self.get_update_statement(ident) + # print(sql) + # print(args) if dbif: dbif.execute(sql, args, mapset=mapset) @@ -560,6 +567,8 @@ def update_all(self, dbif=None, ident=None) -> None: mapset = get_current_mapset() sql, args = self.get_update_all_statement(ident) + # print(sql) + # print(args) if dbif: dbif.execute(sql, args, mapset=mapset) diff --git a/python/grass/temporal/core.py b/python/grass/temporal/core.py index 2391c07765e..4ac181a3f81 100644 --- a/python/grass/temporal/core.py +++ b/python/grass/temporal/core.py @@ -1262,6 +1262,7 @@ def execute(self, statement, args=None, mapset=None): + self._create_mapset_error_message(mapset) ) ) + return self.connections[mapset].execute(statement, args) def fetchone(self, mapset=None): From 49ebe72bb20264a2c5259e68a8b764db82e01e4c Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 21 Sep 2026 13:31:49 +0200 Subject: [PATCH 14/18] try adding blas & lapack --- .github/workflows/osgeo4w.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/osgeo4w.yml b/.github/workflows/osgeo4w.yml index 96b5a6bb9aa..ec79a5db272 100644 --- a/.github/workflows/osgeo4w.yml +++ b/.github/workflows/osgeo4w.yml @@ -55,11 +55,13 @@ jobs: tar zip pacboy: >- + blas bzip2 ccache fftw gcc gettext + lapack libiconv libsystre libtre From abff2e66a89bf5190c0e2fa0ff62fd326dfde447 Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 21 Sep 2026 13:32:21 +0200 Subject: [PATCH 15/18] no fork on mac --- python/grass/script/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/grass/script/utils.py b/python/grass/script/utils.py index 142d88864b5..94d8037e41a 100644 --- a/python/grass/script/utils.py +++ b/python/grass/script/utils.py @@ -27,6 +27,7 @@ import uuid import random import string +import sys from pathlib import Path from typing import TYPE_CHECKING, AnyStr, TypeVar, cast, overload @@ -148,7 +149,7 @@ def get_fork_context(): """ import multiprocessing - if "fork" in multiprocessing.get_all_start_methods(): + if sys.platform != "darwin" and "fork" in multiprocessing.get_all_start_methods(): return multiprocessing.get_context("fork") return multiprocessing.get_context() From fb2b2ccf126c0b287c52a6e37411fd4b5295536e Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 21 Sep 2026 13:38:26 +0200 Subject: [PATCH 16/18] no blas & lapack --- .github/workflows/osgeo4w.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/osgeo4w.yml b/.github/workflows/osgeo4w.yml index ec79a5db272..a5e8a334291 100644 --- a/.github/workflows/osgeo4w.yml +++ b/.github/workflows/osgeo4w.yml @@ -55,18 +55,15 @@ jobs: tar zip pacboy: >- - blas bzip2 ccache fftw gcc gettext - lapack libiconv libsystre libtre libwinpthread-git - openblas pcre pkgconf toolchain From de1a786fb8ad18c60cbb1739c127411d43c209cd Mon Sep 17 00:00:00 2001 From: ninsbl Date: Mon, 21 Sep 2026 13:38:37 +0200 Subject: [PATCH 17/18] no blas & lapack --- mswindows/osgeo4w/build_osgeo4w.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mswindows/osgeo4w/build_osgeo4w.sh b/mswindows/osgeo4w/build_osgeo4w.sh index f1549f0e637..71bf096c484 100755 --- a/mswindows/osgeo4w/build_osgeo4w.sh +++ b/mswindows/osgeo4w/build_osgeo4w.sh @@ -37,7 +37,7 @@ CXXFLAGS="$CXXFLAGS -pipe" \ --includedir=${OSGEO4W_ROOT_MSYS}/include \ --libexecdir=${OSGEO4W_ROOT_MSYS}/bin \ --prefix=${OSGEO4W_ROOT_MSYS}/apps/grass \ - --with-blas \ + --without-blas \ --with-bzlib \ --with-cairo \ --with-cairo-includes=${OSGEO4W_ROOT_MSYS}/include/cairo \ From 4dd12f15a28164ea27acc7523c9b25947b06ba86 Mon Sep 17 00:00:00 2001 From: ninsbl Date: Wed, 23 Sep 2026 14:25:12 +0200 Subject: [PATCH 18/18] reduce comment --- python/grass/script/utils.py | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/python/grass/script/utils.py b/python/grass/script/utils.py index 94d8037e41a..522c96a637c 100644 --- a/python/grass/script/utils.py +++ b/python/grass/script/utils.py @@ -127,25 +127,15 @@ def resolve_nprocs(nprocs: int | str) -> int: def get_fork_context(): - """Return a :mod:`multiprocessing` context using the ``fork`` start method. - - Several GRASS Python APIs start worker processes that are expected to - inherit the exact environment and GRASS session state (for example - ``GISRC``/current mapset set by :func:`grass.script.setup.init`) of - their parent at the time they are started. That assumption only holds - for the ``fork`` start method, where the child is a copy of the parent - process. It does not hold for ``spawn``, and, notably, not for - ``forkserver`` either: its workers are forked from a long-lived helper - process instead of the caller, and so only see the environment that - existed when that helper process itself was started, which can be - stale. Python 3.14 made ``forkserver`` the default start method on Linux - (previously ``fork``), so callers that rely on this inheritance must - request ``fork`` explicitly. - - ``fork`` is unavailable on Windows, which has always defaulted to - ``spawn``; the default context is returned there instead. - - .. versionadded:: 8.6 + """Return a suitable :mod:`multiprocessing` fork context. + + The GRASS Python APIs does not yet support `spawn` and `forkserver` + start-methods for multiprocessing, which became new default in + Python 3.14. This function provides a temporary workaround that + switches back to `fork` on Linux until the new default methods are + supported. + The function should be removed as soon as the GRASS Python APIs support + the new default start-methods. """ import multiprocessing