Skip to content

Commit d4e9540

Browse files
authored
r.pack: Replace traceback by error message for output issues (#6346)
Add check for issues with the output file parent directory, and report error messages. This avoids a traceback originating at the very end of the tool execution when calling shutil.move. This adds tests for the newly handled error states as well as for other issues with the inputs. Skip the read-only dir test for Windows. At least in GitHub Actions Windows CI, this does not cause r.pack to fail. Assuming here that the directory still allows writes, we can't fully set up the test, so we don't test that for Windows. It adds the new pytest fixtures to scripts conftest.py file given there is at least some potential for reusability for similar simple tests.
1 parent 23b5a22 commit d4e9540

File tree

3 files changed

+182
-0
lines changed

3 files changed

+182
-0
lines changed

scripts/conftest.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,54 @@
44
SPDX-License-Identifier: GPL-2.0-or-later
55
"""
66

7+
import os
78
from collections.abc import Generator
89

910
import pytest
1011

12+
import grass.script as gs
13+
from grass.tools import Tools
14+
from grass.experimental import TemporaryMapsetSession
15+
1116

1217
@pytest.fixture(scope="module")
1318
def monkeypatch_module() -> Generator[pytest.MonkeyPatch]:
1419
"""Yield a monkeypatch context, through a module-scoped fixture"""
1520
with pytest.MonkeyPatch.context() as mp:
1621
yield mp
22+
23+
24+
@pytest.fixture(scope="module")
25+
def xy_raster_dataset_session_for_module(tmp_path_factory):
26+
"""Active session in an XY location with small data (scope: function)"""
27+
project = tmp_path_factory.mktemp("scripts_raster_dataset") / "xy_test"
28+
gs.create_project(project)
29+
with (
30+
gs.setup.init(project, env=os.environ.copy()) as session,
31+
Tools(session=session) as tools,
32+
):
33+
tools.g_region(s=0, n=5, w=0, e=6, res=1, flags="s")
34+
tools.r_mapcalc(expression="rows_raster = row()")
35+
yield session
36+
37+
38+
@pytest.fixture
39+
def xy_raster_dataset_session_mapset(xy_raster_dataset_session_for_module):
40+
"""A session in a temporary mapset"""
41+
with TemporaryMapsetSession(
42+
env=xy_raster_dataset_session_for_module.env
43+
) as session:
44+
yield session
45+
46+
47+
@pytest.fixture
48+
def xy_empty_dataset_session(tmp_path):
49+
"""Active session in an XY location (scope: function)"""
50+
project = tmp_path / "xy_test"
51+
gs.create_project(project)
52+
with (
53+
gs.setup.init(project, env=os.environ.copy()) as session,
54+
Tools(session=session) as tools,
55+
):
56+
tools.g_region(s=0, n=5, w=0, e=6, res=1)
57+
yield session

scripts/r.pack/r.pack.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
from pathlib import Path
4141

42+
import grass.script as gs
4243
from grass.script.utils import try_rmdir, try_remove
4344
from grass.script import core as grass
4445

@@ -71,6 +72,7 @@ def main():
7172
if not gfile["name"]:
7273
grass.fatal(_("Raster map <%s> not found") % infile)
7374

75+
parent_dir = Path(outfile).parent
7476
if os.path.exists(outfile):
7577
if os.getenv("GRASS_OVERWRITE"):
7678
grass.warning(
@@ -79,6 +81,12 @@ def main():
7981
try_remove(outfile)
8082
else:
8183
grass.fatal(_("option <output>: <%s> exists.") % outfile)
84+
elif not parent_dir.exists():
85+
gs.fatal(_("Directory '{path}' does not exist").format(path=parent_dir))
86+
elif not parent_dir.is_dir():
87+
gs.fatal(_("Path '{path}' is not a directory").format(path=parent_dir))
88+
elif not os.access(parent_dir, os.W_OK):
89+
gs.fatal(_("Directory '{path}' is not writable").format(path=parent_dir))
8290

8391
grass.message(_("Packing <%s> to <%s>...") % (gfile["fullname"], outfile))
8492
basedir = os.path.sep.join(os.path.normpath(gfile["file"]).split(os.path.sep)[:-2])
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import os
2+
import re
3+
import stat
4+
import sys
5+
6+
import pytest
7+
8+
from grass.exceptions import CalledModuleError
9+
from grass.tools import Tools
10+
11+
12+
@pytest.mark.parametrize("compression_flag", ["", "c"])
13+
@pytest.mark.parametrize(
14+
"suffix",
15+
[".pack", ".rpack", ".r_pack", ".grass_raster", ".grr", ".arbitrary_suffix_1"],
16+
)
17+
def test_pack_different_suffixes(
18+
xy_raster_dataset_session_for_module, tmp_path, suffix, compression_flag
19+
):
20+
"""Check that non-zero output is created with different suffixes
21+
22+
The compression should not change the allowed suffixes.
23+
"""
24+
tools = Tools(session=xy_raster_dataset_session_for_module)
25+
output = (tmp_path / "output_1").with_suffix(suffix)
26+
tools.r_pack(input="rows_raster", output=output, flags=compression_flag)
27+
assert output.exists()
28+
assert output.is_file()
29+
assert output.stat().st_size > 0
30+
31+
32+
def test_input_does_not_exist(xy_empty_dataset_session, tmp_path):
33+
"""Check we raise when input does not exists"""
34+
tools = Tools(session=xy_empty_dataset_session)
35+
output = tmp_path / "output_1.rpack"
36+
with pytest.raises(CalledModuleError, match="does_not_exist"):
37+
tools.r_pack(input="does_not_exist", output=output)
38+
39+
40+
def test_output_exists_without_overwrite(
41+
xy_raster_dataset_session_for_module, tmp_path
42+
):
43+
"""Check behavior when output already exists"""
44+
tools = Tools(session=xy_raster_dataset_session_for_module)
45+
output = tmp_path / "output_1.rpack"
46+
assert not output.exists()
47+
tools.r_pack(input="rows_raster", output=output)
48+
assert output.exists()
49+
with pytest.raises(
50+
CalledModuleError, match=rf"(?s){re.escape(str(output))}.*[Oo]verwrite"
51+
):
52+
# Same call repeated again.
53+
tools.r_pack(input="rows_raster", output=output)
54+
assert output.exists()
55+
56+
57+
def test_output_exists_with_overwrite(xy_raster_dataset_session_for_module, tmp_path):
58+
"""Check behavior when output already exists and overwrite is set"""
59+
tools = Tools(session=xy_raster_dataset_session_for_module)
60+
output = tmp_path / "output_1.rpack"
61+
assert not output.exists()
62+
tools.r_pack(input="rows_raster", output=output)
63+
assert output.exists()
64+
# Same call repeated again but with overwrite.
65+
tools.r_pack(input="rows_raster", output=output, overwrite=True)
66+
assert output.exists()
67+
68+
69+
def test_output_dir_does_not_exist(xy_raster_dataset_session_for_module, tmp_path):
70+
"""Check behavior when directory for output does not exist"""
71+
tools = Tools(session=xy_raster_dataset_session_for_module)
72+
output = tmp_path / "does_not_exist" / "output_1.rpack"
73+
assert not output.exists()
74+
assert not output.parent.exists()
75+
with pytest.raises(
76+
CalledModuleError,
77+
match=rf"(?s)'{re.escape(str(output.parent))}'.*does not exist",
78+
):
79+
tools.r_pack(input="rows_raster", output=output)
80+
81+
82+
def test_output_dir_is_file(xy_raster_dataset_session_for_module, tmp_path):
83+
"""Check behavior when what should be a directory is a file"""
84+
tools = Tools(session=xy_raster_dataset_session_for_module)
85+
parent = tmp_path / "file_as_dir"
86+
parent.touch()
87+
output = parent / "output_1.rpack"
88+
assert not output.exists()
89+
assert output.parent.exists()
90+
assert output.parent.is_file()
91+
with pytest.raises(
92+
CalledModuleError,
93+
match=rf"(?s)'{re.escape(str(output.parent))}'.*is not.*directory",
94+
):
95+
tools.r_pack(input="rows_raster", output=output)
96+
97+
98+
@pytest.mark.skipif(sys.platform.startswith("win"), reason="Dir still writable")
99+
def test_output_dir_is_not_writable(xy_raster_dataset_session_for_module, tmp_path):
100+
"""Check behavior when directory is not writable"""
101+
tools = Tools(session=xy_raster_dataset_session_for_module)
102+
parent = tmp_path / "parent_dir"
103+
parent.mkdir()
104+
# This should work for Windows according to the doc, but it does not.
105+
parent.chmod(stat.S_IREAD)
106+
output = parent / "output_1.rpack"
107+
# Calling output.exists() gives permission denied on Linux, but os.path does not.
108+
assert not os.path.exists(output)
109+
assert output.parent.exists()
110+
assert output.parent.is_dir()
111+
with pytest.raises(
112+
CalledModuleError,
113+
match=rf"(?s)'{re.escape(str(output.parent))}'.*is not.*writable",
114+
):
115+
tools.r_pack(input="rows_raster", output=output)
116+
117+
118+
@pytest.mark.parametrize("compression_flag", ["", "c"])
119+
def test_round_trip_with_r_unpack(
120+
xy_raster_dataset_session_mapset, tmp_path, compression_flag
121+
):
122+
"""Check that we get the same values with r.unpack"""
123+
tools = Tools(session=xy_raster_dataset_session_mapset)
124+
output = tmp_path / "output_1.rpack"
125+
tools.r_pack(input="rows_raster", output=output, flags=compression_flag)
126+
imported = "imported"
127+
tools.r_unpack(input=output, output=imported)
128+
tools.r_mapcalc(expression="diff = rows_raster - imported")
129+
stats = tools.r_univar(map="diff", format="json")
130+
assert stats["n"] > 1
131+
assert stats["sum"] == 0
132+
assert stats["min"] == 0
133+
assert stats["max"] == 0

0 commit comments

Comments
 (0)