From b2bcc7b3886e87007a73ed206b0de4bd2d10a380 Mon Sep 17 00:00:00 2001 From: ninsbl Date: Tue, 23 Jun 2026 10:20:41 +0200 Subject: [PATCH 1/5] document mapset option --- temporal/t.connect/t.connect.md | 101 ++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/temporal/t.connect/t.connect.md b/temporal/t.connect/t.connect.md index fa04ed27b21..3438281cccf 100644 --- a/temporal/t.connect/t.connect.md +++ b/temporal/t.connect/t.connect.md @@ -8,6 +8,16 @@ The **-p** flag will display the current temporal database connection parameters. Use the **format** option to specify the output format: `plain`, `shell`, or `json` — with `plain` being the default. +**format=json** always returns a JSON array. By default it contains one entry +per mapset on the current search path. + +The optional **mapset** option can be used to extend or restrict the selection +of mapsets to report the connection information for. Use `.` for the current +mapset only, `*` for all mapsets in the project, or a comma-separated list for +specific mapsets. The **mapset** option requires both the **-p** flag and +**format=json**; the **format=plain** and **format=shell** do not support +multi-mapset output. + The **-g** flag is deprecated and will be removed in a future release. Please use **format=shell** option with the **-p** flag instead. @@ -38,6 +48,97 @@ PostgreSQL and SQLite databases can not be mixed in a project. ## EXAMPLES +### Print the current connection in different formats + +Print in plain format (default): + +```sh +t.connect -p +``` + +```sh +driver:sqlite +database:$GISDBASE/$LOCATION_NAME/$MAPSET/tgis/sqlite.db +``` + +Print in shell format: + +```sh +t.connect -p format=shell +``` + +```sh +driver=sqlite +database=$GISDBASE/$LOCATION_NAME/$MAPSET/tgis/sqlite.db +``` + +Print as JSON (reports all mapsets on the search path as an array): + +```sh +t.connect -p format=json +``` + +```json +[ + { + "mapset": "PERMANENT", + "driver": "sqlite", + "database": "$GISDBASE/$LOCATION_NAME/PERMANENT/tgis/sqlite.db" + }, + { + "mapset": "user1", + "driver": "sqlite", + "database": "$GISDBASE/$LOCATION_NAME/user1/tgis/sqlite.db" + } +] +``` + +Restrict JSON output to the current mapset only: + +```sh +t.connect -p format=json mapset=. +``` + +```json +[ + { + "mapset": "user1", + "driver": "sqlite", + "database": "$GISDBASE/$LOCATION_NAME/user1/tgis/sqlite.db" + } +] +``` + +### Print connection for a subset of mapsets + +Restrict JSON output to specific mapsets: + +```sh +t.connect -p format=json mapset=PERMANENT,user1 +``` + +```json +[ + { + "mapset": "PERMANENT", + "driver": "sqlite", + "database": "$GISDBASE/$LOCATION_NAME/PERMANENT/tgis/sqlite.db" + }, + { + "mapset": "user1", + "driver": "sqlite", + "database": "$GISDBASE/$LOCATION_NAME/user1/tgis/sqlite.db" + } +] +``` + +Use `mapset=*` to include all mapsets in the project, not just those on the +search path: + +```sh +t.connect -p format=json mapset=* +``` + ### SQLite The SQLite database is created automatically when used the first time. From b966d8fd14cb3cdef07421c2bd0a0bd2cb0cdc88 Mon Sep 17 00:00:00 2001 From: ninsbl Date: Tue, 23 Jun 2026 10:22:08 +0200 Subject: [PATCH 2/5] add mapset option --- temporal/t.connect/main.c | 162 ++++++++++++++++++++++++++------------ 1 file changed, 113 insertions(+), 49 deletions(-) diff --git a/temporal/t.connect/main.c b/temporal/t.connect/main.c index 28bffee5dcf..bdfd535ab54 100644 --- a/temporal/t.connect/main.c +++ b/temporal/t.connect/main.c @@ -22,16 +22,40 @@ enum OutputFormat { PLAIN, SHELL, JSON }; +static void print_tgis_connection(const char *mapset, G_JSON_Array *json_array) +{ + char *drv = tgis_get_mapset_driver_name(mapset); + char *db = tgis_get_mapset_database_name(mapset); + G_JSON_Value *ms_value = G_json_value_init_object(); + + if (!ms_value) + G_fatal_error(_("Failed to initialize JSON object. Out of memory?")); + G_JSON_Object *ms_object = G_json_object(ms_value); + + G_json_object_set_string(ms_object, "mapset", mapset); + if (drv) + G_json_object_set_string(ms_object, "driver", drv); + else + G_json_object_set_null(ms_object, "driver"); + if (db) + G_json_object_set_string(ms_object, "database", db); + else + G_json_object_set_null(ms_object, "database"); + G_json_array_append_value(json_array, ms_value); + + G_free(drv); + G_free(db); +} + int main(int argc, char *argv[]) { dbConnection conn; struct Flag *print, *check_set_default, *def, *sh; - struct Option *driver, *database, *format_opt; + struct Option *driver, *database, *format_opt, *mapset_opt; struct GModule *module; enum OutputFormat format; G_JSON_Value *root_value = NULL; - G_JSON_Object *root_object = NULL; /* Initialize the GIS calls */ G_gisinit(argv[0]); @@ -88,18 +112,24 @@ int main(int argc, char *argv[]) database->answer = (char *)tgis_get_default_database_name(); database->guisection = _("Set"); + mapset_opt = G_define_standard_option(G_OPT_M_MAPSET); + mapset_opt->multiple = YES; + mapset_opt->required = NO; + mapset_opt->label = + _("Name of the mapset(s) to print the connection info for"); + mapset_opt->description = + _("'.' for current mapset; '*' for all mapsets in the project. " + "Requires format=json."); + mapset_opt->guisection = _("Print"); + + G_option_requires(mapset_opt, print, NULL); + if (G_parser(argc, argv)) exit(EXIT_FAILURE); if (format_opt->answer != NULL) { if (strcmp(format_opt->answer, "json") == 0) { format = JSON; - root_value = G_json_value_init_object(); - if (root_value == NULL) { - G_fatal_error( - _("Failed to initialize JSON object. Out of memory?")); - } - root_object = G_json_object(root_value); } else if (strcmp(format_opt->answer, "shell") == 0) { format = SHELL; @@ -132,61 +162,95 @@ int main(int argc, char *argv[]) _("The -p flag is required when using the format option.")); } + if (mapset_opt->answer && format != JSON) { + if (root_value) + G_json_value_free(root_value); + G_fatal_error(_("The mapset option requires format=json.")); + } + if (print->answer) { - if (tgis_get_connection(&conn) == DB_OK) { - switch (format) { - case SHELL: - fprintf(stdout, "driver=%s\n", - conn.driverName ? conn.driverName : ""); - fprintf(stdout, "database=%s\n", - conn.databaseName ? conn.databaseName : ""); - break; - - case PLAIN: - fprintf(stdout, "driver:%s\n", - conn.driverName ? conn.driverName : ""); - fprintf(stdout, "database:%s\n", - conn.databaseName ? conn.databaseName : ""); - break; - - case JSON: - if (conn.driverName) - G_json_object_set_string(root_object, "driver", - conn.driverName); - else - G_json_object_set_null(root_object, "driver"); - - if (conn.databaseName) - G_json_object_set_string(root_object, "database", - conn.databaseName); - else - G_json_object_set_null(root_object, "database"); - break; + if (format == JSON) { + /* JSON always outputs an array. When mapset is not specified, + * report connections for all mapsets on the current search path. */ + G_JSON_Array *json_array = NULL; + char *json_string; + + root_value = G_json_value_init_array(); + if (!root_value) + G_fatal_error( + _("Failed to initialize JSON array. Out of memory?")); + json_array = G_json_array(root_value); + + if (mapset_opt->answers) { + int i; + + for (i = 0; mapset_opt->answers[i]; i++) { + const char *ms_arg = mapset_opt->answers[i]; + + if (strcmp(ms_arg, "*") == 0) { + char **ms_list = G_get_available_mapsets(); + int j; + + for (j = 0; ms_list[j]; j++) + print_tgis_connection(ms_list[j], json_array); + } + else { + const char *ms = + strcmp(ms_arg, ".") == 0 ? G_mapset() : ms_arg; + + print_tgis_connection(ms, json_array); + } + } } - } - else { - if (root_value) - G_json_value_free(root_value); - G_fatal_error(_("Temporal GIS database connection not defined. " - "Run t.connect.")); - } + else { + /* default: all mapsets on the current search path */ + const char *ms; + int j; - if (format == JSON) { - char *json_string = G_json_serialize_to_string_pretty(root_value); + for (j = 0; (ms = G_get_mapset_name(j)); j++) + print_tgis_connection(ms, json_array); + } + + json_string = G_json_serialize_to_string_pretty(root_value); if (!json_string) { G_json_value_free(root_value); G_fatal_error(_("Failed to serialize JSON to pretty format.")); } - fputs(json_string, stdout); fputc('\n', stdout); - G_json_free_serialized_string(json_string); G_json_value_free(root_value); } + else { + /* PLAIN / SHELL: report the current mapset's connection */ + if (tgis_get_connection(&conn) == DB_OK) { + switch (format) { + case SHELL: + fprintf(stdout, "driver=%s\n", + conn.driverName ? conn.driverName : ""); + fprintf(stdout, "database=%s\n", + conn.databaseName ? conn.databaseName : ""); + break; + + case PLAIN: + fprintf(stdout, "driver:%s\n", + conn.driverName ? conn.driverName : ""); + fprintf(stdout, "database:%s\n", + conn.databaseName ? conn.databaseName : ""); + break; + + case JSON: + /* unreachable: JSON is handled above */ + break; + } + } + else { + G_fatal_error(_("Temporal GIS database connection not defined. " + "Run t.connect.")); + } + } fflush(stdout); - exit(EXIT_SUCCESS); } From b6b876177fe0198b11c6fdfec45b329ab353ad73 Mon Sep 17 00:00:00 2001 From: ninsbl Date: Tue, 23 Jun 2026 10:22:52 +0200 Subject: [PATCH 3/5] add tests for mapset option --- temporal/t.connect/tests/conftest.py | 23 +++++++ temporal/t.connect/tests/test_t_connect.py | 71 +++++++++++++++++++++- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/temporal/t.connect/tests/conftest.py b/temporal/t.connect/tests/conftest.py index 5070fa1b03e..a747984e0a9 100644 --- a/temporal/t.connect/tests/conftest.py +++ b/temporal/t.connect/tests/conftest.py @@ -39,3 +39,26 @@ def empty_session(tmp_path_factory): with gs.setup.init(project, env=os.environ.copy()) as session: yield SimpleNamespace(session=session) + + +@pytest.fixture(scope="module") +def multi_mapset_session(tmp_path_factory): + """Start a session with PERMANENT and user1, both with connections set. + + The session is left in user1, which has PERMANENT on its default search + path, so both mapsets appear in the search-path-default JSON output. + """ + tmp_path = tmp_path_factory.mktemp("t_connect_multi") + project = tmp_path / "test_project_multi" + gs.create_project(project) + + with gs.setup.init(project, env=os.environ.copy()) as session: + tools = Tools(session=session) + db_path = "$GISDBASE/$LOCATION_NAME/$MAPSET/tgis/sqlite.db" + + tools.t_connect(driver="sqlite", database=db_path) + + gs.run_command("g.mapset", mapset="user1", flags="c", env=session.env) + tools.t_connect(driver="sqlite", database=db_path) + + yield SimpleNamespace(session=session) diff --git a/temporal/t.connect/tests/test_t_connect.py b/temporal/t.connect/tests/test_t_connect.py index e200b8e70c2..2b4d5b0195d 100644 --- a/temporal/t.connect/tests/test_t_connect.py +++ b/temporal/t.connect/tests/test_t_connect.py @@ -38,13 +38,15 @@ def test_print_shell(t_connect_session): def test_print_json(t_connect_session): - """Check that the JSON format parses correctly and matches connection values.""" + """Check that format=json returns an array with one entry per search-path mapset.""" tools = Tools(session=t_connect_session.session) result = tools.t_connect(flags="p", format="json") - assert result["driver"] == "sqlite" - assert result["database"] == "$GISDBASE/$LOCATION_NAME/$MAPSET/tgis/sqlite.db" + assert len(result) == 1 + assert result[0]["mapset"] == "PERMANENT" + assert result[0]["driver"] == "sqlite" + assert result[0]["database"] == "$GISDBASE/$LOCATION_NAME/$MAPSET/tgis/sqlite.db" @pytest.mark.parametrize("output_format", ["shell", "json"]) @@ -62,3 +64,66 @@ def test_incompatible_flags(t_connect_session): with pytest.raises(ToolError): tools.t_connect(flags="g", format="json") + + +def test_json_search_path_default(multi_mapset_session): + """Check that format=json without mapset reports all search-path mapsets. + + The session is in user1, whose default search path includes PERMANENT, + so both mapsets should appear in the output. + """ + tools = Tools(session=multi_mapset_session.session) + + result = tools.t_connect(flags="p", format="json") + mapsets = [entry["mapset"] for entry in result] + + assert "PERMANENT" in mapsets + assert "user1" in mapsets + + +def test_json_mapset_dot_restricts_to_current(multi_mapset_session): + """Check that mapset=. limits JSON output to the current mapset only.""" + tools = Tools(session=multi_mapset_session.session) + + result = tools.t_connect(flags="p", format="json", mapset=".") + + assert len(result) == 1 + assert result[0]["mapset"] == "user1" + + +def test_json_mapset_explicit_name(t_connect_session): + """Check that an explicit mapset name returns exactly that mapset.""" + tools = Tools(session=t_connect_session.session) + + result = tools.t_connect(flags="p", format="json", mapset="PERMANENT") + + assert len(result) == 1 + assert result[0]["mapset"] == "PERMANENT" + assert result[0]["driver"] == "sqlite" + + +def test_json_mapset_star_includes_all(multi_mapset_session): + """Check that mapset=* reports connections for every mapset in the project.""" + tools = Tools(session=multi_mapset_session.session) + + result = tools.t_connect(flags="p", format="json", mapset="*") + mapsets = {entry["mapset"] for entry in result} + + assert "PERMANENT" in mapsets + assert "user1" in mapsets + + +def test_mapset_requires_json_format(t_connect_session): + """Check that combining the mapset option with a non-JSON format raises an error.""" + tools = Tools(session=t_connect_session.session) + + with pytest.raises(ToolError): + tools.t_connect(flags="p", format="shell", mapset=".") + + +def test_mapset_requires_print_flag(t_connect_session): + """Check that using the mapset option without -p raises an error.""" + tools = Tools(session=t_connect_session.session) + + with pytest.raises(ToolError): + tools.t_connect(mapset=".", format="json") From bfd692c04e836db9977234a3a0864699329062c9 Mon Sep 17 00:00:00 2001 From: ninsbl Date: Tue, 23 Jun 2026 10:43:03 +0200 Subject: [PATCH 4/5] add testcase for empty VAR file --- temporal/t.connect/tests/test_t_connect.py | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/temporal/t.connect/tests/test_t_connect.py b/temporal/t.connect/tests/test_t_connect.py index 2b4d5b0195d..95d151bcf84 100644 --- a/temporal/t.connect/tests/test_t_connect.py +++ b/temporal/t.connect/tests/test_t_connect.py @@ -1,7 +1,10 @@ """Test current baseline functionality of t.connect""" +import pathlib + import pytest +import grass.script as gs from grass.tools import ToolError, Tools @@ -127,3 +130,29 @@ def test_mapset_requires_print_flag(t_connect_session): with pytest.raises(ToolError): tools.t_connect(mapset=".", format="json") + + +def test_print_does_not_write_var_file(empty_session): + """Verify that print-only operations leave no TGIS entries in the VAR file. + + Calling t.connect with -p (in any output format) must never write + connection settings to the mapset's VAR file. + """ + tools = Tools(session=empty_session.session) + + tools.t_connect(flags="p") + tools.t_connect(flags="p", format="shell") + tools.t_connect(flags="p", format="json") + + gisenv = gs.gisenv(env=empty_session.session.env) + var_file = ( + pathlib.Path(gisenv["GISDBASE"]) + / gisenv["LOCATION_NAME"] + / gisenv["MAPSET"] + / "VAR" + ) + + if var_file.exists(): + contents = var_file.read_text() + assert "TGISDB_DRIVER" not in contents + assert "TGISDB_DATABASE" not in contents From d8765056009ce9697484a6913eea80d3f11e461a Mon Sep 17 00:00:00 2001 From: ninsbl Date: Thu, 9 Jul 2026 15:36:40 +0200 Subject: [PATCH 5/5] make TGIS DB connections more robust --- python/grass/temporal/core.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/python/grass/temporal/core.py b/python/grass/temporal/core.py index 2789224fecb..d2ed6e7b92a 100644 --- a/python/grass/temporal/core.py +++ b/python/grass/temporal/core.py @@ -1375,8 +1375,12 @@ def connect(self, dbstring: str | None = None) -> None: self.connection.isolation_level = None self.connection.text_factory = str self.cursor = self.connection.cursor() - self.cursor.execute("PRAGMA synchronous = OFF") - self.cursor.execute("PRAGMA journal_mode = MEMORY") + # busy_timeout is set to 30 seconds to avoid "database is locked" errors + self.cursor.executescript( + "PRAGMA synchronous = OFF;" + "PRAGMA journal_mode = MEMORY;" + "PRAGMA busy_timeout = 30000;" + ) elif self.dbmi.__name__ == "psycopg2": self.connection = self.dbmi.connect(dbstring) # self.connection.set_isolation_level(dbmi.extensions.ISOLATION_LEVEL_AUTOCOMMIT) @@ -1570,13 +1574,11 @@ def execute(self, statement, args=None): else: self.cursor.execute(statement) except db_errors: - if connected: - self.close() self.msgr.error(_("Unable to execute :\n %(sql)s") % {"sql": statement}) raise - - if connected: - self.close() + finally: + if connected: + self.close() def fetchone(self): if self.connected: @@ -1601,27 +1603,22 @@ def execute_transaction(self, statement, mapset=None): self.connect() connected = True - sql_script = "" - sql_script += "BEGIN TRANSACTION;\n" - sql_script += statement - sql_script += "END TRANSACTION;" + sql_script = f"BEGIN TRANSACTION;\n{statement};\nEND TRANSACTION;" try: if self.dbmi.__name__ == "sqlite3": - self.cursor.executescript(statement) + self.cursor.executescript(sql_script) else: self.cursor.execute(statement) self.connection.commit() except db_errors: - if connected: - self.close() self.msgr.error( _("Unable to execute transaction:\n %(sql)s") % {"sql": statement} ) raise - - if connected: - self.close() + finally: + if connected: + self.close() ###############################################################################