diff --git a/python/grass/app/tests/grass_app_cli_run_pack_test.py b/python/grass/app/tests/grass_app_cli_run_pack_test.py new file mode 100644 index 00000000000..8ffe0645941 --- /dev/null +++ b/python/grass/app/tests/grass_app_cli_run_pack_test.py @@ -0,0 +1,132 @@ +import json +import sys +import subprocess + +import pytest + + +def test_run_with_crs_as_pack_as_input(pack_raster_file4x5_rows): + """Check that we accept pack as input.""" + result = subprocess.run( + [ + sys.executable, + "-m", + "grass.app", + "run", + "--crs", + str(pack_raster_file4x5_rows), + "r.univar", + f"map={pack_raster_file4x5_rows}", + "format=json", + ], + capture_output=True, + text=True, + check=True, + ) + assert ( + json.loads(result.stdout)["cells"] == 1 + ) # because we don't set the computational region + + +@pytest.mark.parametrize("crs", ["EPSG:3358", "EPSG:4326"]) +@pytest.mark.parametrize("extension", [".grass_raster", ".grr", ".rpack"]) +def test_run_with_crs_as_pack_as_output(tmp_path, crs, extension): + """Check outputting pack with different CRSs and extensions""" + raster = tmp_path / f"test{extension}" + subprocess.run( + [ + sys.executable, + "-m", + "grass.app", + "run", + "--crs", + crs, + "r.mapcalc.simple", + "expression=row() + col()", + f"output={raster}", + ], + check=True, + ) + assert raster.exists() + assert raster.is_file() + result = subprocess.run( + [ + sys.executable, + "-m", + "grass.app", + "run", + "--crs", + str(raster), + "g.proj", + "-p", + "format=json", + ], + capture_output=True, + text=True, + check=True, + ) + assert json.loads(result.stdout)["srid"] == crs + + +def test_run_with_crs_as_pack_with_multiple_steps(tmp_path): + """Check that we accept pack as both input and output. + + The extension is only tested for the output. + Tests basic properties of the output. + """ + crs = "EPSG:3358" + extension = ".grass_raster" + raster_a = tmp_path / f"test_a{extension}" + raster_b = tmp_path / f"test_b{extension}" + subprocess.run( + [ + sys.executable, + "-m", + "grass.app", + "run", + "--crs", + crs, + "r.mapcalc.simple", + "expression=row() + col()", + f"output={raster_a}", + ], + check=True, + ) + assert raster_a.exists() + assert raster_a.is_file() + subprocess.run( + [ + sys.executable, + "-m", + "grass.app", + "run", + "--crs", + crs, + "r.mapcalc.simple", + "expression=1.5 * A", + f"a={raster_a}", + f"output={raster_b}", + ], + check=True, + ) + assert raster_b.exists() + assert raster_b.is_file() + result = subprocess.run( + [ + sys.executable, + "-m", + "grass.app", + "run", + "--crs", + crs, + "r.univar", + f"map={raster_b}", + "format=json", + ], + capture_output=True, + text=True, + check=True, + ) + assert ( + json.loads(result.stdout)["cells"] == 1 + ) # because we don't set the computational region diff --git a/python/grass/tools/Makefile b/python/grass/tools/Makefile index 8820c884347..61a10a62472 100644 --- a/python/grass/tools/Makefile +++ b/python/grass/tools/Makefile @@ -6,6 +6,7 @@ include $(MODULE_TOPDIR)/include/Make/Python.make DSTDIR = $(ETC)/python/grass/tools MODULES = \ + importexport \ session_tools \ support diff --git a/python/grass/tools/importexport.py b/python/grass/tools/importexport.py new file mode 100644 index 00000000000..045648ad226 --- /dev/null +++ b/python/grass/tools/importexport.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Literal + + +class ImporterExporter: + """Imports and exports data while keeping track of it + + This is a class for internal use, but it may mature into a generally useful tool. + """ + + raster_pack_suffixes = (".grass_raster", ".pack", ".rpack", ".grr") + + @classmethod + def is_recognized_file(cls, value): + """Return `True` if file type is a recognized type, `False` otherwise""" + return cls.is_raster_pack_file(value) + + @classmethod + def is_raster_pack_file(cls, value): + """Return `True` if file type is GRASS raster pack, `False` otherwise""" + if isinstance(value, str): + return value.endswith(cls.raster_pack_suffixes) + if isinstance(value, Path): + return value.suffix in cls.raster_pack_suffixes + return False + + def __init__(self, *, run_function, run_cmd_function): + self._run_function = run_function + self._run_cmd_function = run_cmd_function + # At least for reading purposes, public access to the lists makes sense. + self.input_rasters: list[tuple[Path, str]] = [] + self.output_rasters: list[tuple[Path, str]] = [] + self.current_input_rasters: list[tuple[Path, str]] = [] + self.current_output_rasters: list[tuple[Path, str]] = [] + + def process_parameter_list(self, command, **popen_options): + """Ingests any file for later imports and exports and replaces arguments + + This function is relatively costly as it calls a subprocess to digest the parameters. + + Returns the list of parameters with inputs and outputs replaced so that a tool + will understand that, i.e., file paths into data names in a project. + """ + # Get processed parameters to distinguish inputs and outputs. + # We actually don't know the type of the input or outputs) because that is + # currently not included in --json. Consequently, we are only assuming that the + # files are meant to be used as in-project data. So, we need to deal with cases + # where that's not true one by one, such as r.unpack taking file, + # not raster (cell), so the file needs to be left as is. + parameters = self._process_parameters(command, **popen_options) + tool_name = parameters["module"] + args = command.copy() + # We will deal with inputs right away + if "inputs" in parameters: + for item in parameters["inputs"]: + if tool_name != "r.unpack" and self.is_raster_pack_file(item["value"]): + in_project_name = self._to_name(item["value"]) + record = (Path(item["value"]), in_project_name) + if ( + record not in self.output_rasters + and record not in self.input_rasters + and record not in self.current_input_rasters + ): + self.current_input_rasters.append(record) + for i, arg in enumerate(args): + if arg.startswith(f"{item['param']}="): + arg = arg.replace(item["value"], in_project_name) + args[i] = arg + if "outputs" in parameters: + for item in parameters["outputs"]: + if tool_name != "r.pack" and self.is_raster_pack_file(item["value"]): + in_project_name = self._to_name(item["value"]) + record = (Path(item["value"]), in_project_name) + # Following the logic of r.slope.aspect, we don't deal with one output repeated + # more than once, but this would be the place to address it. + if ( + record not in self.output_rasters + and record not in self.current_output_rasters + ): + self.current_output_rasters.append(record) + for i, arg in enumerate(args): + if arg.startswith(f"{item['param']}="): + arg = arg.replace(item["value"], in_project_name) + args[i] = arg + return args + + def _process_parameters(self, command, **popen_options): + """Get parameters processed by the tool itself""" + popen_options["stdin"] = None + popen_options["stdout"] = subprocess.PIPE + # We respect whatever is in the stderr option because that's what the user + # asked for and will expect to get in case of error (we pretend that it was + # the intended run, not our special run before the actual run). + return self._run_cmd_function([*command, "--json"], **popen_options) + + def _to_name(self, value, /): + return Path(value).stem + + def import_rasters(self, rasters, *, env): + for raster_file, in_project_name in rasters: + # Overwriting here is driven by the run function. + self._run_function( + "r.unpack", + input=raster_file, + output=in_project_name, + superquiet=True, + env=env, + ) + + def export_rasters( + self, rasters, *, env, delete_first: bool, overwrite: Literal[True] | None + ): + # Pack the output raster + for raster_file, in_project_name in rasters: + # Overwriting a file is a warning, so to avoid it, we delete the file first. + # This creates a behavior consistent with command line tools. + if delete_first: + Path(raster_file).unlink(missing_ok=True) + + # Overwriting here is driven by the run function and env. + self._run_function( + "r.pack", + input=in_project_name, + output=raster_file, + flags="c", + superquiet=True, + env=env, + overwrite=overwrite, + ) + + def import_data(self, *, env): + # We import the data, make records for later, and the clear the current list. + self.import_rasters(self.current_input_rasters, env=env) + self.input_rasters.extend(self.current_input_rasters) + self.current_input_rasters = [] + + def export_data( + self, *, env, delete_first: bool = False, overwrite: Literal[True] | None = None + ): + # We export the data, make records for later, and the clear the current list. + self.export_rasters( + self.current_output_rasters, + env=env, + delete_first=delete_first, + overwrite=overwrite, + ) + self.output_rasters.extend(self.current_output_rasters) + self.current_output_rasters = [] + + def cleanup(self, *, env): + # We don't track in what mapset the rasters are, and we assume + # the mapset was not changed in the meantime. + remove = [name for (unused, name) in self.input_rasters] + remove.extend([name for (unused, name) in self.output_rasters]) + if remove: + self._run_function( + "g.remove", + type="raster", + name=remove, + superquiet=True, + flags="f", + env=env, + ) + self.input_rasters = [] + self.output_rasters = [] diff --git a/python/grass/tools/session_tools.py b/python/grass/tools/session_tools.py index b0f18bbccaf..61c9b1db3d2 100644 --- a/python/grass/tools/session_tools.py +++ b/python/grass/tools/session_tools.py @@ -19,6 +19,7 @@ import grass.script as gs from grass.exceptions import CalledModuleError +from .importexport import ImporterExporter from .support import ParameterConverter, ToolFunctionResolver, ToolResult @@ -194,6 +195,55 @@ class Tools: >>> result.text '' + + Although using arrays incurs an overhead cost compared to using only + in-project data, the array interface provides a convenient workflow + when NumPy arrays are used with other array functions. + + If a tool accepts a single raster input or output, a native GRASS raster pack + format can be used in the same way as an in-project raster or NumPy array. + GRASS native rasters are recognized by `.grass_raster`, `.grr`, and `.rpack` + extensions. All approaches can be combined in one workflow: + + >>> with Tools(session=session) as tools: + ... tools.r_slope_aspect( + ... elevation=np.ones((2, 3)), slope="slope.grass_raster", aspect="aspect" + ... ) + ... statistics = tools.r_univar(map="slope.grass_raster", format="json") + >>> # File now exists + >>> from pathlib import Path + >>> Path("slope.grass_raster").is_file() + True + >>> # In-project raster now exists + >>> tools.r_info(map="aspect", format="json")["cells"] + 6 + + When the *Tools* object is used as a context manager, in-project data created as + part of handling the raster files will be cached and will not be imported again + when used in the following steps. The cache is cleared at the end of the context. + When the *Tools* object is not used as a context manager, the cashing can be + enabled by `use_cache=True`. Explicitly enabled cache requires explicit cleanup: + + >>> tools = Tools(session=session, use_cache=True) + >>> tools.r_univar(map="slope.grass_raster", format="json")["cells"] + 6 + >>> tools.r_info(map="slope.grass_raster", format="json")["cells"] + 6 + >>> tools.cleanup() + + Notably, the above code works also with `use_cache=False` (or the default), + but the file will be imported twice, once for each tool call, so using + context manager or managing the cache explicitly is good for reducing the + overhead which the external rasters bring compared to using in-project data. + + For parallel processing, create separate Tools objects. Each Tools instance + can operate with the same or different sessions or environments, as well as with + :py:class:`grass.script.RegionManager` and :py:class:`grass.script.MaskManager`. + When working exclusively with data within a project, objects are lightweight + and add negligible overhead compared to direct subprocess calls. + Using NumPy or out-of-project native GRASS raster files, adds computational + and IO cost, but generally not more than the cost of the same operation done + directly without the aid of a Tools object. """ def __init__( @@ -209,6 +259,7 @@ def __init__( capture_output=True, capture_stderr=None, consistent_return_value=False, + use_cache=None, ): """ If session is provided and has an env attribute, it is used to execute tools. @@ -253,6 +304,13 @@ def __init__( Additionally, this can be used to obtain both NumPy arrays and text outputs from a tool call. + While using of cache is primarily driven by the use of the object as + a context manager, cashing can be explicitly enabled or disabled with + the *use_cache* parameter. The cached data is kept in the current + mapset so that it is available as tool inputs. Without a context manager, + explicit `use_cache=True` requires explicit call to *cleanup* to remove + the data from the current mapset. + If *env* or other *Popen* arguments are provided to one of the tool running functions, the constructor parameters except *errors* are ignored. """ @@ -275,6 +333,11 @@ def __init__( self._capture_stderr = capture_stderr self._name_resolver = None self._consistent_return_value = consistent_return_value + self._importer_exporter = None + # Decides if we delete at each run or only at the end of context. + self._delete_on_context_exit = False + # User request to keep the data. + self._use_cache = use_cache def _modified_env_if_needed(self): """Get the environment for subprocesses @@ -349,10 +412,11 @@ def run(self, tool_name_: str, /, **kwargs): ) # We approximate original kwargs with the possibly-modified kwargs. - result = self.run_cmd( + result = self._run_cmd( args, - tool_kwargs=kwargs, + tool_kwargs=kwargs, # We send the original kwargs for error reporting. input=object_parameter_handler.stdin, + parameter_converter=object_parameter_handler, **popen_options, ) use_objects = object_parameter_handler.translate_data_to_objects( @@ -378,24 +442,87 @@ def run_cmd( command: list[str], *, input: str | bytes | None = None, + parameter_converter: ParameterConverter | None = None, tool_kwargs: dict | None = None, **popen_options, ): """Run a tool by passing its name and parameters a list of strings. - The function may perform additional processing on the parameters. + The function will perform additional processing on the parameters + such as importing GRASS native raster files to in-project data. + + :param command: list of strings to execute as the command + :param input: text input for the standard input of the tool + :param **popen_options: additional options for :py:func:`subprocess.Popen` + """ + return self._run_cmd(command, input=input, **popen_options) + + def _run_cmd( + self, + command: list[str], + *, + input: str | bytes | None = None, + parameter_converter: ParameterConverter | None = None, + tool_kwargs: dict | None = None, + **popen_options, + ): + """Run a tool by passing its name and parameters a list of strings. + + If parameters were already processed using a *ParameterConverter* instance, + the instance can be passed as the *parameter_converter* parameter, avoiding + re-processing. :param command: list of strings to execute as the command :param input: text input for the standard input of the tool + :param parameter_converter: a Parameter converter instance if already used :param tool_kwargs: named tool arguments used for error reporting (experimental) :param **popen_options: additional options for :py:func:`subprocess.Popen` """ - return self.call_cmd( - command, - tool_kwargs=tool_kwargs, - input=input, - **popen_options, - ) + # Compute the environment for subprocesses and store it for later use. + if "env" not in popen_options: + popen_options["env"] = self._modified_env_if_needed() + + if parameter_converter is None: + # Parameters were not processed yet, so process them now. + parameter_converter = ParameterConverter() + parameter_converter.process_parameter_list(command[1:]) + try: + # Processing parameters for import and export is costly, so we do it + # only when we previously determined there might be such parameters. + if parameter_converter.import_export: + if self._importer_exporter is None: + # The importer exporter instance may be reused in later calls + # based on how the cache is used. + self._importer_exporter = ImporterExporter( + run_function=self.call, run_cmd_function=self.call_cmd + ) + command = self._importer_exporter.process_parameter_list( + command, **popen_options + ) + # The command now has external files replaced with in-project data, + # so now we import the data. + self._importer_exporter.import_data(env=popen_options["env"]) + result = self.call_cmd( + command, + tool_kwargs=tool_kwargs, # used in error reporting + input=input, + **popen_options, + ) + if parameter_converter.import_export: + # Exporting data inherits the overwrite flag from the command + # if provided, otherwise it is driven by the environment. + overwrite = None + if "--o" in command or "--overwrite" in command: + overwrite = True + self._importer_exporter.export_data( + env=popen_options["env"], overwrite=overwrite + ) + finally: + if parameter_converter.import_export: + if not self._delete_on_context_exit and not self._use_cache: + # Delete the in-project data after each call. + self._importer_exporter.cleanup(env=popen_options["env"]) + return result def call(self, tool_name_: str, /, **kwargs): """Run a tool by specifying its name as a string and parameters. @@ -421,7 +548,7 @@ def call_cmd(self, command, tool_kwargs=None, input=None, **popen_options): defaults and return value. :param command: list of strings to execute as the command - :param tool_kwargs: named tool arguments used for error reporting (experimental) + :param tool_kwargs: named tool arguments used for error reporting :param input: text input for the standard input of the tool :param **popen_options: additional options for :py:func:`subprocess.Popen` """ @@ -504,7 +631,14 @@ def __enter__(self): :returns: reference to the object (self) """ + self._delete_on_context_exit = True return self def __exit__(self, exc_type, exc_value, traceback): """Exit the context manager context.""" + if not self._use_cache: + self.cleanup() + + def cleanup(self): + if self._importer_exporter is not None: + self._importer_exporter.cleanup(env=self._modified_env_if_needed()) diff --git a/python/grass/tools/support.py b/python/grass/tools/support.py index 3ca33b2ed64..593e7be654d 100644 --- a/python/grass/tools/support.py +++ b/python/grass/tools/support.py @@ -40,6 +40,8 @@ # ga is present as well because that's the only import-time failure we expect. ga = None +from .importexport import ImporterExporter + class ParameterConverter: """Converts parameter values to strings and facilitates flow of the data.""" @@ -51,6 +53,7 @@ def __init__(self): self.stdin = None self.result = None self.temporary_rasters = [] + self.import_export = None def process_parameters(self, kwargs): """Converts high level parameter values to strings. @@ -81,6 +84,24 @@ def process_parameters(self, kwargs): elif isinstance(value, StringIO): kwargs[key] = "-" self.stdin = value.getvalue() + elif self.import_export is None and ImporterExporter.is_recognized_file( + value + ): + self.import_export = True + if self.import_export is None: + self.import_export = False + + def process_parameter_list(self, command): + """Converts or at least processes parameters passed as list of strings""" + for item in command: + splitted = item.split("=", maxsplit=1) + value = splitted[1] if len(splitted) > 1 else item + if self.import_export is None and ImporterExporter.is_recognized_file( + value + ): + self.import_export = True + if self.import_export is None: + self.import_export = False def translate_objects_to_data(self, kwargs, env): """Convert NumPy arrays to GRASS data""" diff --git a/python/grass/tools/tests/conftest.py b/python/grass/tools/tests/conftest.py index ede5e7c6bde..ae1edd1f76c 100644 --- a/python/grass/tools/tests/conftest.py +++ b/python/grass/tools/tests/conftest.py @@ -49,3 +49,94 @@ def empty_string_result(): @pytest.fixture def echoing_resolver(): return ToolFunctionResolver(run_function=lambda x: x, env=os.environ.copy()) + + +@pytest.fixture(scope="module") +def rows_raster_file3x2(tmp_path_factory): + """Native raster pack file + + Smallest possible file, but with rows and columns greater than one, + and a different number of rows and columns. + """ + tmp_path = tmp_path_factory.mktemp("rows_raster_file3x2") + project = tmp_path / "xy_test3x2" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + gs.run_command("g.region", rows=3, cols=2, env=session.env) + gs.mapcalc("rows = row()", env=session.env) + output_file = tmp_path / "rows3x2.grass_raster" + gs.run_command( + "r.pack", + input="rows", + output=output_file, + flags="c", + superquiet=True, + env=session.env, + ) + return output_file + + +@pytest.fixture(scope="module") +def rows_raster_file4x5(tmp_path_factory): + """Native raster pack file + + Small file, but slightly larger than the smallest. + """ + tmp_path = tmp_path_factory.mktemp("rows_raster_file4x5") + project = tmp_path / "xy_test4x5" + gs.create_project(project) + with gs.setup.init(project, env=os.environ.copy()) as session: + gs.run_command("g.region", rows=4, cols=5, env=session.env) + gs.mapcalc("rows = row()", env=session.env) + output_file = tmp_path / "rows4x5.grass_raster" + gs.run_command( + "r.pack", + input="rows", + output=output_file, + flags="c", + superquiet=True, + env=session.env, + ) + return output_file + + +@pytest.fixture(scope="module") +def ones_raster_file_epsg3358(tmp_path_factory): + """Native raster pack with EPSG:3358""" + tmp_path = tmp_path_factory.mktemp("ones_raster_file4x5") + project = tmp_path / "xy_test4x5" + gs.create_project(project, crs="EPSG:3358") + with gs.setup.init(project, env=os.environ.copy()) as session: + gs.run_command("g.region", rows=4, cols=5, env=session.env) + gs.mapcalc("ones = 1", env=session.env) + output_file = tmp_path / "ones4x5.grass_raster" + gs.run_command( + "r.pack", + input="ones", + output=output_file, + flags="c", + superquiet=True, + env=session.env, + ) + return output_file + + +@pytest.fixture(scope="module") +def ones_raster_file_epsg4326(tmp_path_factory): + """Native raster pack with EPSG:4326 (LL)""" + tmp_path = tmp_path_factory.mktemp("ones_raster_file4x5") + project = tmp_path / "xy_test4x5" + gs.create_project(project, crs="EPSG:4326") + with gs.setup.init(project, env=os.environ.copy()) as session: + gs.run_command("g.region", rows=4, cols=5, env=session.env) + gs.mapcalc("ones = 1", env=session.env) + output_file = tmp_path / "ones4x5.grass_raster" + gs.run_command( + "r.pack", + input="ones", + output=output_file, + flags="c", + superquiet=True, + env=session.env, + ) + return output_file diff --git a/python/grass/tools/tests/grass_tools_session_tools_pack_test.py b/python/grass/tools/tests/grass_tools_session_tools_pack_test.py new file mode 100644 index 00000000000..afd36d26ea7 --- /dev/null +++ b/python/grass/tools/tests/grass_tools_session_tools_pack_test.py @@ -0,0 +1,767 @@ +"""Test pack import-export functionality of grass.tools.Tools class""" + +import os +import re +from pathlib import Path + +import pytest + +import grass.script as gs +from grass.tools import Tools, ToolError + + +def test_pack_input_output_tool_name_function( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check input and output pack files work with tool name call""" + tools = Tools(session=xy_dataset_session) + tools.g_region(rows=3, cols=3) + assert os.path.exists(rows_raster_file3x2) + output_file = tmp_path / "file.grass_raster" + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=output_file) + assert output_file.exists() + assert not tools.g_findfile(element="raster", file="file", format="json")["name"] + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert not tools.g_list(type="raster", format="json") + + +@pytest.mark.parametrize("parameter_type", [str, Path]) +def test_pack_input_output_tool_name_function_string_value( + xy_dataset_session, rows_raster_file3x2, tmp_path, parameter_type +): + """Check input and output pack files work string a parameter + + We make no assumption about the fixture types and explicitly test all + supported parameter types. + """ + tools = Tools(session=xy_dataset_session) + tools.g_region(rows=3, cols=3) + assert os.path.exists(rows_raster_file3x2) + output_file = tmp_path / "file.grass_raster" + tools.r_slope_aspect( + elevation=parameter_type(rows_raster_file3x2), slope=parameter_type(output_file) + ) + assert output_file.exists() + assert not tools.g_findfile(element="raster", file="file", format="json")["name"] + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert not tools.g_list(type="raster", format="json") + + +def test_pack_input_output_with_name_and_parameter_call( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check input and output pack files work with tool name as string""" + tools = Tools(session=xy_dataset_session) + tools.g_region(rows=3, cols=3) + assert os.path.exists(rows_raster_file3x2) + output_file = tmp_path / "file.grass_raster" + tools.run("r.slope.aspect", elevation=rows_raster_file3x2, slope=output_file) + assert output_file.exists() + assert not tools.g_findfile(element="raster", file="file", format="json")["name"] + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert not tools.g_list(type="raster", format="json") + + +def test_pack_input_output_with_subprocess_run_like_call( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check input and output pack files work with command as list""" + tools = Tools(session=xy_dataset_session) + assert os.path.exists(rows_raster_file3x2) + output_file = tmp_path / "file.grass_raster" + tools.run_cmd( + [ + "r.slope.aspect", + f"elevation={rows_raster_file3x2}", + f"aspect={output_file}", + ] + ) + assert output_file.exists() + assert not tools.g_findfile(element="raster", file="file", format="json")["name"] + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert not tools.g_list(type="raster", format="json") + + +def test_no_modify_command(xy_dataset_session, rows_raster_file3x2, tmp_path): + """Check that input command is not modified by the function""" + tools = Tools(session=xy_dataset_session) + tools.g_region(rows=3, cols=3) + output_file = tmp_path / "file.grass_raster" + command = [ + "r.slope.aspect", + f"elevation={rows_raster_file3x2}", + f"slope={output_file}", + ] + original = command.copy() + tools.run_cmd(command) + assert original == command + + +def test_io_cleanup_after_function(xy_dataset_session, rows_raster_file3x2, tmp_path): + """Check input and output rasters are deleted after function call""" + tools = Tools(session=xy_dataset_session) + tools.g_region(rows=3, cols=3) + output_file = tmp_path / "file.grass_raster" + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=output_file) + assert output_file.exists() + assert not tools.g_findfile(element="raster", file="file", format="json")["name"] + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert not tools.g_list(type="raster", format="json") + + +def test_io_cleanup_after_context(xy_dataset_session, rows_raster_file3x2, tmp_path): + """Check input and output rasters are deleted at the end of context""" + output_file_1 = tmp_path / "file.grass_raster" + output_file_2 = tmp_path / "file2.grass_raster" + with Tools(session=xy_dataset_session) as tools: + tools.g_region(rows=3, cols=3) + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=output_file_1) + assert output_file_1.exists() + assert tools.g_findfile(element="raster", file="file", format="json")["name"] + tools.r_mapcalc_simple(expression="100 * A", a="file", output=output_file_2) + assert output_file_2.exists() + assert tools.g_findfile(element="raster", file="file2", format="json")["name"] + # The pack files should still exist. + assert output_file_1.exists() + assert output_file_2.exists() + # The in-project rasters should not exist. + assert not tools.g_findfile(element="raster", file="file", format="json")["name"] + assert not tools.g_findfile(element="raster", file="file2", format="json")["name"] + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert not tools.g_list(type="raster", format="json") + + +def test_io_no_cleanup(xy_dataset_session, rows_raster_file3x2, tmp_path): + """Check input and output rasters are deleted only with explicit cleanup call""" + output_file = tmp_path / "file.grass_raster" + tools = Tools(session=xy_dataset_session, use_cache=True) + tools.g_region(rows=3, cols=3) + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=output_file) + assert output_file.exists() + # Files should still be available. + assert tools.g_findfile(element="raster", file="file", format="json")["name"] + assert tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + # But an explicit cleanup should delete the files. + tools.cleanup() + assert not tools.g_findfile(element="raster", file="file", format="json")["name"] + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert not tools.g_list(type="raster", format="json") + + +def test_io_no_cleanup_with_context(xy_dataset_session, rows_raster_file3x2, tmp_path): + """Check input and output rasters are kept even with context""" + file_1 = tmp_path / "file_1.grass_raster" + file_2 = tmp_path / "file_2.grass_raster" + with Tools(session=xy_dataset_session, use_cache=True) as tools: + tools.g_region(rows=3, cols=3) + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=file_1) + assert file_1.exists() + assert tools.g_findfile(element="raster", file=file_1.stem, format="json")[ + "name" + ] + tools.r_mapcalc_simple(expression="100 * A", a=file_1.stem, output=file_2) + assert file_2.exists() + assert tools.g_findfile(element="raster", file=file_2.stem, format="json")[ + "name" + ] + # The pack files should still exist. + assert file_1.exists() + assert file_2.exists() + # The in-project rasters should also exist. + assert tools.g_findfile(element="raster", file=file_1.stem, format="json")["name"] + assert tools.g_findfile(element="raster", file=file_2.stem, format="json")["name"] + assert tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + # But an explicit cleanup should delete the files. + tools.cleanup() + assert not tools.g_findfile(element="raster", file=file_1.stem, format="json")[ + "name" + ] + assert not tools.g_findfile(element="raster", file=file_2.stem, format="json")[ + "name" + ] + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert not tools.g_list(type="raster", format="json") + # The pack files should still exist after cleanup. + assert file_1.exists() + assert file_2.exists() + + +def test_multiple_input_usages_with_context(xy_dataset_session, rows_raster_file3x2): + """Check multiple usages of the same input raster with context""" + with Tools(session=xy_dataset_session) as tools: + tools.g_region(raster=rows_raster_file3x2) + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope="slope") + tools.r_mapcalc_simple( + expression="100 * A", a=rows_raster_file3x2, output="a100" + ) + assert tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert tools.g_findfile(element="raster", file="slope", format="json")["name"] + assert tools.g_findfile(element="raster", file="a100", format="json")["name"] + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + + +def test_multiple_input_usages_with_use_cache(xy_dataset_session, rows_raster_file3x2): + """Check input and output rasters are kept even with context""" + tools = Tools(session=xy_dataset_session, use_cache=True) + tools.g_region(raster=rows_raster_file3x2) + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope="slope") + tools.r_mapcalc_simple(expression="100 * A", a=rows_raster_file3x2, output="a100") + assert tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert tools.g_findfile(element="raster", file="slope", format="json")["name"] + assert tools.g_findfile(element="raster", file="a100", format="json")["name"] + tools.cleanup() + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + + +def test_multiple_input_usages_with_defaults(xy_dataset_session, rows_raster_file3x2): + """Check input and output rasters are kept even with context""" + tools = Tools(session=xy_dataset_session) + tools.g_region(rows=3, cols=3) + tools.r_mapcalc_simple( + expression="A + B", + a=rows_raster_file3x2, + b=rows_raster_file3x2, + output="output", + ) + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert tools.g_findfile(element="raster", file="output", format="json")["name"] + + +def test_creation_and_use_with_context( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check that we can create an external file and then use the file later""" + slope = tmp_path / "slope.grass_raster" + with Tools(session=xy_dataset_session) as tools: + tools.g_region(raster=rows_raster_file3x2) + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=slope) + assert tools.r_univar(map=slope, format="json")["cells"] == 6 + assert tools.g_findfile(element="raster", file=slope.stem, format="json")[ + "name" + ] + assert not tools.g_findfile(element="raster", file=slope.stem, format="json")[ + "name" + ] + assert slope.exists() + + +def test_creation_and_use_with_use_cache( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check that we can create an external file and then use the file later""" + slope = tmp_path / "slope.grass_raster" + tools = Tools(session=xy_dataset_session, use_cache=True) + tools.g_region(raster=rows_raster_file3x2) + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=slope) + assert tools.r_univar(map=slope, format="json")["cells"] == 6 + assert tools.g_findfile(element="raster", file=slope.stem, format="json")["name"] + assert slope.exists() + + +def test_creation_and_use_with_defaults( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check that we can create an external file and then use the file later""" + slope = tmp_path / "slope.grass_raster" + tools = Tools(session=xy_dataset_session) + tools.g_region(raster=rows_raster_file3x2) + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=slope) + assert tools.r_univar(map=slope, format="json")["cells"] == 6 + assert not tools.g_findfile(element="raster", file=slope.stem, format="json")[ + "name" + ] + assert slope.exists() + + +def test_repeated_input_usages_with_context(xy_dataset_session, rows_raster_file3x2): + """Check multiple usages of the same input raster with context""" + with Tools(session=xy_dataset_session) as tools: + tools.g_region(rows=3, cols=3) + tools.r_mapcalc_simple( + expression="A + B", + a=rows_raster_file3x2, + b=rows_raster_file3x2, + output="output", + ) + assert tools.g_findfile(element="raster", file="output", format="json")["name"] + assert tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert tools.g_findfile(element="raster", file="output", format="json")["name"] + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + + +def test_repeated_output(xy_dataset_session, rows_raster_file3x2, tmp_path): + """Check behavior when two outputs have the same name + + This would ideally result in error or some other clear state, but at least + r.slope.aspect has that as undefined behavior, so we follow the same logic. + Here, we test the current behavior which is that no error is produced + and one of the outputs is produced (but it is not defined which one). + """ + tools = Tools(session=xy_dataset_session) + tools.g_region(rows=3, cols=3) + output_file = tmp_path / "file.grass_raster" + tools.r_slope_aspect( + elevation=rows_raster_file3x2, slope=output_file, aspect=output_file + ) + assert output_file.exists() + + +def test_output_without_overwrite(xy_dataset_session, rows_raster_file3x2, tmp_path): + """Check input and output pack files work with tool name call""" + tools = Tools(session=xy_dataset_session) + tools.g_region(rows=3, cols=3) + assert os.path.exists(rows_raster_file3x2) + output_file = tmp_path / "file.grass_raster" + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=output_file) + with pytest.raises(ToolError, match=r"[Oo]verwrite"): + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=output_file) + assert output_file.exists() + + +def test_output_with_object_level_overwrite( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check input and output pack files work with tool name call""" + tools = Tools(session=xy_dataset_session, overwrite=True) + tools.g_region(rows=3, cols=3) + assert os.path.exists(rows_raster_file3x2) + output_file = tmp_path / "file.grass_raster" + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=output_file) + # Same call the second time. + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=output_file) + assert output_file.exists() + + +def test_output_with_function_level_overwrite( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check input and output pack files work with tool name call""" + tools = Tools(session=xy_dataset_session) + tools.g_region(rows=3, cols=3) + assert os.path.exists(rows_raster_file3x2) + output_file = tmp_path / "file.grass_raster" + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=output_file) + # Same call the second time. + tools.r_slope_aspect( + elevation=rows_raster_file3x2, slope=output_file, overwrite=True + ) + assert output_file.exists() + + +def test_non_existent_pack_input(xy_dataset_session, tmp_path): + """Check input and output pack files work with tool name call""" + tools = Tools(session=xy_dataset_session) + tools.g_region(rows=3, cols=3) + input_file = tmp_path / "does_not_exist.grass_raster" + assert not input_file.exists() + with pytest.raises( + ToolError, + match=rf"(?s)[^/\/a-zA-Z_]{re.escape(str(input_file))}[^/\/a-zA-Z_].*not found", + ): + tools.r_slope_aspect(elevation=input_file, slope="slope") + assert not tools.g_findfile(element="raster", file=input_file.stem, format="json")[ + "name" + ] + assert not tools.g_findfile(element="raster", file="slope", format="json")["name"] + + +def test_non_existent_output_pack_directory( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check input and output pack files work with tool name call""" + tools = Tools(session=xy_dataset_session) + tools.g_region(rows=3, cols=3) + output_file = tmp_path / "does_not_exist" / "file.grass_raster" + assert not output_file.exists() + assert not output_file.parent.exists() + assert rows_raster_file3x2.exists() + with pytest.raises( + ToolError, + match=rf"(?s)[^/\/a-zA-Z_]{re.escape(str(output_file.parent))}[^/\/a-zA-Z_].*does not exist", + ): + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=output_file) + + +def test_wrong_parameter(xy_dataset_session, rows_raster_file3x2, tmp_path): + """Check wrong parameter causes standard exception + + Since the tool is called to process its parameters with pack IO, + the error handling takes a different path than without pack IO active. + """ + tools = Tools(session=xy_dataset_session) + tools.g_region(rows=3, cols=3) + with pytest.raises(ToolError, match="does_not_exist"): + tools.r_slope_aspect( + elevation=rows_raster_file3x2, + slope="file.grass_raster", + does_not_exist="test", + ) + + +def test_direct_r_unpack_to_data(xy_dataset_session, rows_raster_file3x2): + """Check that we can r.unpack data as usual""" + tools = Tools(session=xy_dataset_session, use_cache=True) + tools.g_region(rows=3, cols=3) + name = "data_1" + tools.r_unpack(input=rows_raster_file3x2, output=name) + assert tools.g_findfile(element="raster", file=name, format="json")["name"] + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + + +def test_direct_r_unpack_to_pack(xy_dataset_session, rows_raster_file3x2, tmp_path): + """Check that roundtrip from existing packed raster to new packed raster works""" + tools = Tools(session=xy_dataset_session, use_cache=True) + tools.g_region(rows=3, cols=3) + name = "auto_packed_data_1.grass_raster" + packed_file = tmp_path / name + tools.r_unpack(input=rows_raster_file3x2, output=packed_file) + assert packed_file.exists() + assert tools.g_findfile(element="raster", file=packed_file.stem, format="json")[ + "name" + ] + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + + +def test_direct_r_pack_from_data(xy_dataset_session, tmp_path): + """Check that we can r.pack data as usual""" + tools = Tools(session=xy_dataset_session, use_cache=True) + tools.g_region(rows=3, cols=3) + tools.r_mapcalc(expression="data_1 = 1") + name = "manually_packed_data_1.grass_raster" + packed_file = tmp_path / name + tools.r_pack(input="data_1", output=packed_file) + # New file was created. + assert packed_file.exists() + # Input still exists. + assert tools.g_findfile(element="raster", file="data_1", format="json")["name"] + # There should be no raster created automatically. + assert not tools.g_findfile(element="raster", file=packed_file.stem, format="json")[ + "name" + ] + tools.cleanup() + # Input still exists even after cleaning. + assert tools.g_findfile(element="raster", file="data_1", format="json")["name"] + + +def test_direct_r_pack_from_pack(xy_dataset_session, rows_raster_file3x2, tmp_path): + """Check that roundtrip from existing packed raster to raster works""" + tools = Tools(session=xy_dataset_session, use_cache=True) + tools.g_region(rows=3, cols=3) + name = "manually_packed_data_1.grass_raster" + packed_file = tmp_path / name + tools.r_pack(input=rows_raster_file3x2, output=packed_file) + # New file was created. + assert packed_file.exists() + # Input still exists. + assert rows_raster_file3x2.exists() + # Auto-imported raster should exist. + assert tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + # There should be no raster created automatically. + assert not tools.g_findfile(element="raster", file=packed_file.stem, format="json")[ + "name" + ] + tools.cleanup() + # Auto-imported raster should be deleted. + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + + +def test_clean_after_tool_failure_with_context_and_try( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check we delete imported input when we fail after that import. + + A realistic code example with try-finally blocks, but without an explicit check + that the exception was raised. + + We don't test multiple raster, assuming that either all are removed or all kept. + """ + try: + with Tools(session=xy_dataset_session) as tools: + tools.r_mapcalc_simple( + expression="A + does_not_exist", a=rows_raster_file3x2, output="output" + ) + except ToolError: + pass + finally: + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + + +def test_clean_after_tool_failure_with_context_and_raises( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check input and output pack files work with tool name call + + Checks that the exception was actually raised, but does not show the intention + as clearly as the test with try-finally. + """ + with ( + pytest.raises(ToolError, match=r"r\.mapcalc\.simple"), + Tools(session=xy_dataset_session) as tools, + ): + tools.r_mapcalc_simple( + expression="A + does_not_exist", a=rows_raster_file3x2, output="output" + ) + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert rows_raster_file3x2.exists() + + +def test_clean_after_tool_failure_without_context( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check we delete imported input when we fail after that import. + + A single call should clean after itself unless told otherwise. + """ + tools = Tools(session=xy_dataset_session) + with pytest.raises(ToolError, match=r"r\.mapcalc\.simple"): + tools.r_mapcalc_simple( + expression="A + does_not_exist", a=rows_raster_file3x2, output="output" + ) + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert rows_raster_file3x2.exists() + + +def test_clean_after_tool_failure_without_context_with_use_cache( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check we don't delete imported input even after failure when asked. + + When explicitly requested, we wait for explicit request to delete the imported + data even after a failure. + """ + tools = Tools(session=xy_dataset_session, use_cache=True) + with pytest.raises(ToolError, match=r"r\.mapcalc\.simple"): + tools.r_mapcalc_simple( + expression="A + does_not_exist", a=rows_raster_file3x2, output="output" + ) + assert tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + tools.cleanup() + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert rows_raster_file3x2.exists() + + +def test_clean_after_call_failure_with_context_and_try( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check we delete imported input when we fail after that import. + + A realistic code example with try-finally blocks, but without an explicit check + that the exception was raised. + + We don't test multiple raster, assuming that either all are removed or all kept. + """ + try: + with Tools(session=xy_dataset_session) as tools: + tools.g_region(rows=3, cols=3) + output_file = tmp_path / "does_not_exist" / "file.grass_raster" + assert not output_file.parent.exists() + # Non-existence of a directory will be failure inside r.pack which is + # what we use to get an internal failure inside the call. + # This relies on inputs being resolved before outputs. + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=output_file) + except ToolError: + pass + finally: + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert rows_raster_file3x2.exists() + + +def test_clean_after_call_failure_with_context_and_raises( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check input and output pack files work with tool name call + + Checks that the exception was actually raised, but does not show the intention + as clearly as the test with try-finally. + """ + with Tools(session=xy_dataset_session) as tools: + tools.g_region(rows=3, cols=3) + output_file = tmp_path / "does_not_exist" / "file.grass_raster" + assert not output_file.parent.exists() + # Non-existence of a directory will be failure inside r.pack which is + # what we use to get an internal failure inside the call. + # This relies on inputs being resolved before outputs. + with pytest.raises(ToolError, match=r"r\.pack"): + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=output_file) + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert rows_raster_file3x2.exists() + + +def test_clean_after_call_failure_without_context( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check we delete imported input when we fail after that import. + + A single call should clean after itself unless told otherwise. + """ + tools = Tools(session=xy_dataset_session) + tools.g_region(rows=3, cols=3) + output_file = tmp_path / "does_not_exist" / "file.grass_raster" + assert not output_file.parent.exists() + with pytest.raises(ToolError, match=r"r\.pack"): + # Non-existence of a directory will be failure inside r.pack which is + # what we use to get an internal failure inside the call. + # This relies on inputs being resolved before outputs. + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=output_file) + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert rows_raster_file3x2.exists() + + +def test_clean_after_call_failure_without_context_with_use_cache( + xy_dataset_session, rows_raster_file3x2, tmp_path +): + """Check we don't delete imported input even after failure when asked. + + When explicitly requested, we wait for explicit request to delete the imported + data even after a failure. + """ + tools = Tools(session=xy_dataset_session, use_cache=True) + tools.g_region(rows=3, cols=3) + output_file = tmp_path / "does_not_exist" / "file.grass_raster" + assert not output_file.parent.exists() + with pytest.raises(ToolError, match=r"r\.pack"): + # Non-existence of a directory will be failure inside r.pack which is + # what we use to get an internal failure inside the call. + # This relies on inputs being resolved before outputs. + tools.r_slope_aspect(elevation=rows_raster_file3x2, slope=output_file) + assert tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + tools.cleanup() + assert not tools.g_findfile( + element="raster", file=rows_raster_file3x2.stem, format="json" + )["name"] + assert rows_raster_file3x2.exists() + + +def test_workflow_create_project_and_run_general_crs( + tmp_path, ones_raster_file_epsg3358 +): + """Check workflow with create project""" + project = tmp_path / "project" + raster = tmp_path / "raster.grass_raster" + gs.create_project(project, crs=ones_raster_file_epsg3358) + with ( + gs.setup.init(project) as session, + Tools(session=session) as tools, + ): + assert tools.g_region(flags="p", format="json")["crs"]["type"] == "other" + assert tools.g_proj(flags="p", format="json")["srid"] == "EPSG:3358" + tools.g_region(raster=ones_raster_file_epsg3358) + assert tools.g_region(flags="p", format="json")["cells"] == 4 * 5 + tools.r_mapcalc_simple( + expression="2 * A", a=ones_raster_file_epsg3358, output=raster + ) + stats = tools.r_univar(map=raster, format="json") + assert stats["cells"] == 4 * 5 + assert stats["min"] == 2 + assert stats["max"] == 2 + assert stats["mean"] == 2 + assert stats["sum"] == 4 * 5 * 1 * 2 + assert raster.exists() + assert raster.is_file() + + +def test_workflow_create_project_and_run_ll_crs(tmp_path, ones_raster_file_epsg4326): + """Check workflow with create project""" + project = tmp_path / "project" + raster = tmp_path / "raster.grass_raster" + gs.create_project(project, crs=ones_raster_file_epsg4326) + with ( + gs.setup.init(project) as session, + Tools(session=session) as tools, + ): + assert tools.g_region(flags="p", format="json")["crs"]["type"] == "ll" + assert tools.g_proj(flags="p", format="json")["srid"] == "EPSG:4326" + tools.g_region(raster=ones_raster_file_epsg4326) + assert tools.g_region(flags="p", format="json")["cells"] == 4 * 5 + tools.r_mapcalc_simple( + expression="2 * A", a=ones_raster_file_epsg4326, output=raster + ) + stats = tools.r_univar(map=raster, format="json") + assert stats["cells"] == 4 * 5 + assert stats["min"] == 2 + assert stats["max"] == 2 + assert stats["mean"] == 2 + assert stats["sum"] == 4 * 5 * 1 * 2 + assert raster.exists() + assert raster.is_file() + + +def test_workflow_create_project_and_run_xy_crs(tmp_path, rows_raster_file4x5): + """Check workflow with create project""" + project = tmp_path / "project" + raster = tmp_path / "raster.grass_raster" + gs.create_project(project, crs=rows_raster_file4x5) + with ( + gs.setup.init(project) as session, + Tools(session=session) as tools, + ): + assert tools.g_region(flags="p", format="json")["crs"]["type"] == "xy" + tools.g_region(raster=rows_raster_file4x5) + assert tools.g_region(flags="p", format="json")["cells"] == 4 * 5 + tools.r_mapcalc_simple(expression="2 * A", a=rows_raster_file4x5, output=raster) + stats = tools.r_univar(map=raster, format="json") + assert stats["cells"] == 4 * 5 + assert stats["min"] == 2 + assert stats["max"] == 8 + assert raster.exists() + assert raster.is_file() diff --git a/python/grass/tools/tests/grass_tools_session_tools_test.py b/python/grass/tools/tests/grass_tools_session_tools_test.py index 02848789791..b420b28657c 100644 --- a/python/grass/tools/tests/grass_tools_session_tools_test.py +++ b/python/grass/tools/tests/grass_tools_session_tools_test.py @@ -135,7 +135,8 @@ def test_json_with_direct_subprocess_run_like_call(xy_dataset_session): def test_json_as_list(xy_dataset_session): """Check that a JSON result behaves as a list""" tools = Tools(session=xy_dataset_session) - # This also tests JSON parsing with a format option. + # This also tests JSON parsing without a format option + # (which should not have any influence). result = tools.g_search_modules(keyword="random", flags="j") for item in result: assert "name" in item @@ -146,7 +147,6 @@ def test_json_as_list(xy_dataset_session): def test_json_for_pandas(xy_dataset_session): """Check that JSON can be read into Pandas dataframe""" tools = Tools(session=xy_dataset_session) - # This also tests JSON parsing with a format option. result = tools.run("g.region", flags="p", format="json") assert not pd.DataFrame(result).empty @@ -171,7 +171,8 @@ def test_help_call_with_parameters(xy_dataset_session): def test_json_call_with_low_level_call(xy_dataset_session): """Check that --json call works including JSON data parsing""" tools = Tools(session=xy_dataset_session) - # This also tests JSON parsing with a format option. + # This also tests JSON parsing without a format option + # (which should not have any influence). data = tools.call_cmd( ["r.slope.aspect", "elevation=dem", "slope=slope", "--json"] ).json