Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
d905882
grass.experimental: Add object to access modules as functions
wenzeslaus Apr 18, 2023
aaef183
Support verbosity, overwrite and region freezing
wenzeslaus Apr 21, 2023
54db575
Raise exception instead of calling handle_errors
wenzeslaus Apr 22, 2023
82f5894
Allow to specify stdin and use a new instance of Tools itself to exec…
wenzeslaus Apr 22, 2023
0f1e210
Add ignore errors, r_mapcalc example, draft tests
wenzeslaus Apr 22, 2023
f4e3fed
Add test for exceptions
wenzeslaus Apr 24, 2023
04087e8
Add tests and Makefile
wenzeslaus May 4, 2023
6ab8e40
Convert values to ints and floats in keyval
wenzeslaus May 4, 2023
744cfac
Do not overwrite by default to follow default behavior in GRASS GIS
wenzeslaus May 4, 2023
24c27e6
Add doc, remove old code and todos
wenzeslaus Jun 3, 2023
ff187a6
Add to top Makefile
wenzeslaus Jun 3, 2023
22773c8
Add docs for tests
wenzeslaus Jun 3, 2023
2911065
Allow test to fail because of the missing seed parameter (so results …
wenzeslaus Jun 4, 2023
3ac46c3
Merge branch 'main' into add-session-tools-object
echoix Nov 11, 2024
437d46e
Allow for optional output capture (error handling and printing still …
wenzeslaus Apr 23, 2025
cb8f483
Merge branch 'main' into add-session-tools-object
wenzeslaus Apr 23, 2025
a958142
Merge remote-tracking branch 'upstream/main' into add-session-tools-o…
wenzeslaus Apr 25, 2025
61972d4
Access JSON as dict directly without an attribute using getitem. Sugg…
wenzeslaus Apr 25, 2025
c86d8ff
Fix whitespace and regexp
wenzeslaus Apr 25, 2025
3b995c9
Represent not captured stdout as None, not empty string.
wenzeslaus Apr 25, 2025
d8c354d
Merge remote-tracking branch 'upstream/main' into add-session-tools-o…
wenzeslaus Apr 29, 2025
4cc5a32
Add run subcommand to have a CLI use case for the tools. It runs one …
wenzeslaus Apr 29, 2025
459b2ad
Update function name
wenzeslaus Apr 30, 2025
513c9f8
Add prototype code for numpy support
wenzeslaus Jun 2, 2025
24ef6b9
Merge main branch
wenzeslaus Jun 2, 2025
4a1e374
Make the special features standalone objects used by composition
wenzeslaus Jun 11, 2025
651df11
Merge remote-tracking branch 'upstream/main' into add-session-tools-o…
wenzeslaus Jun 11, 2025
ce7c53e
Remove NumPy
wenzeslaus Jun 11, 2025
bd12384
More robust version of getting the parsed CLI from --json
wenzeslaus Jul 2, 2025
4958982
Merge code-wise with main
wenzeslaus Sep 2, 2025
52b29d8
Make the code functional again and align with the grass.tools code. C…
wenzeslaus Sep 2, 2025
0b3f412
Remove rasters which are copies of pack files
wenzeslaus Sep 3, 2025
bd5c6f6
Clean up the tmp files, further sync with grass.tools
wenzeslaus Sep 3, 2025
4d14a47
Merge remote-tracking branch 'upstream/main' into add-pack-files-io-t…
wenzeslaus Sep 3, 2025
faf96a1
Use g.list with JSON in tests
wenzeslaus Sep 3, 2025
1c0bc05
Merge remote-tracking branch 'upstream/main' into add-pack-files-io-t…
wenzeslaus Sep 10, 2025
fc48944
Start the import-export machinery only when there are files in the pa…
wenzeslaus Sep 10, 2025
998255f
Integrate pack code into grass.tools
wenzeslaus Sep 10, 2025
353152d
Move code out of the Tools class, clean up code around ImporterExport…
wenzeslaus Sep 10, 2025
fa1c2a3
Merge remote-tracking branch 'upstream/main' into add-pack-files-io-t…
wenzeslaus Sep 10, 2025
fcbf52f
Doc for tests
wenzeslaus Sep 10, 2025
2eaba0e
Use tmp_path for files in tests. Test supported path representations.
wenzeslaus Sep 10, 2025
b162e39
Remove last piece of grass.experimental.tools from CLI
wenzeslaus Sep 10, 2025
486c6c0
Merge remote-tracking branch 'upstream/main' into add-pack-files-io-t…
wenzeslaus Sep 11, 2025
2a24541
Remove changes from experimental
wenzeslaus Sep 11, 2025
461dd8f
Add understanding of what is imported and track function call versus …
wenzeslaus Sep 12, 2025
bd95881
Merge remote-tracking branch 'upstream/main' into add-pack-files-io-t…
wenzeslaus Sep 12, 2025
f233987
Skip import of inputs when they are known outputs
wenzeslaus Sep 24, 2025
222e7a6
Clean the imported data in case of exceptions
wenzeslaus Sep 24, 2025
5f33224
Clean the imported data in case of exceptions
wenzeslaus Sep 24, 2025
dd61d9e
Merge remote-tracking branch 'upstream/main' into add-pack-files-io-t…
wenzeslaus Sep 24, 2025
4dba4d3
Use single try-finally block
wenzeslaus Sep 24, 2025
fd82a7d
Use use_cache for enabling and disabling the imported and exported da…
wenzeslaus Sep 25, 2025
779f433
Merge remote-tracking branch 'upstream/main' into add-pack-files-io-t…
wenzeslaus Sep 25, 2025
0190eb3
Use ToolError
wenzeslaus Sep 25, 2025
43b326c
Mention overhead in the doc. Use module scope to reduce fixture setup…
wenzeslaus Sep 25, 2025
91912b8
Slightly better wording for arrays.
wenzeslaus Sep 25, 2025
e13a268
Merge remote-tracking branch 'upstream/main' into add-pack-files-io-t…
wenzeslaus Sep 25, 2025
89e9e05
Merge remote-tracking branch 'upstream/main' into add-pack-files-io-t…
wenzeslaus Oct 3, 2025
7ee0208
Missing regexp escape for path
wenzeslaus Oct 3, 2025
e056ba6
Use non-square raster, fix tests
wenzeslaus Oct 3, 2025
07b9c35
Test whole workflow with create_project
wenzeslaus Oct 3, 2025
31a0761
Merge remote-tracking branch 'upstream/main' into add-pack-files-io-t…
wenzeslaus Oct 5, 2025
f667302
Clarify interface of run_cmd by creating a private/protected version …
wenzeslaus Oct 5, 2025
de5e797
Minor cleanup of unrelated comments in tests (related because of the …
wenzeslaus Oct 5, 2025
110041d
Fix typo
wenzeslaus Oct 6, 2025
e7de2b6
Merge branch 'main' into add-pack-files-io-to-tools
wenzeslaus Oct 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions python/grass/app/tests/grass_app_cli_run_pack_test.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions python/grass/tools/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ include $(MODULE_TOPDIR)/include/Make/Python.make
DSTDIR = $(ETC)/python/grass/tools

MODULES = \
importexport \
session_tools \
support

Expand Down
168 changes: 168 additions & 0 deletions python/grass/tools/importexport.py
Original file line number Diff line number Diff line change
@@ -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 = []
Loading
Loading