Skip to content

Commit b73a41f

Browse files
committed
geotiff: GPU writer + dispatcher coverage for CRS fail-closed (#1929)
#1929 added _validate_crs_fallback and wired allow_unparseable_crs into to_geotiff, write_geotiff_gpu, and the to_geotiff(gpu=True) dispatcher. The existing test_crs_fail_closed_1929 only exercises the eager CPU writer; the GPU writer's _validate_crs_fallback call at _writers/gpu.py:507 and the dispatcher thread-through at _writers/eager.py:447 had no targeted tests. A regression dropping the GPU validator or the dispatcher kwarg forward would let write_geotiff_gpu(crs="EPSG:4326") on a host without pyproj silently emit a garbage GTCitationGeoKey that non-libgeotiff readers cannot interpret. Adds 13 tests, all passing on a CUDA host: - write_geotiff_gpu(crs=garbage) raises ValueError (via kwarg + attr) - allow_unparseable_crs=True opt-in restores citation-only write - error message points to all four recovery options - EPSG int + WKT-shaped + no-CRS happy paths still work - to_geotiff(gpu=True) auto-dispatches on cupy data and threads through - to_geotiff(gpu=True, allow_unparseable_crs=True) forwards opt-in - GPU vs CPU error message parity - structural pin: allow_unparseable_crs default is False on both writers Mutation against the GPU validator at _writers/gpu.py:507 flipped 6 tests red; mutation against the dispatcher forward at _writers/eager.py:447 flipped the two opt-in dispatcher tests red.
1 parent 31c8f77 commit b73a41f

1 file changed

Lines changed: 253 additions & 0 deletions

File tree

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
"""GPU + dispatcher backend coverage for issue #1929.
2+
3+
#1929 added ``_validate_crs_fallback`` and wired
4+
``allow_unparseable_crs`` into ``to_geotiff``, ``write_geotiff_gpu``,
5+
and the ``to_geotiff(gpu=True)`` dispatcher. ``test_crs_fail_closed_1929``
6+
only exercises the eager CPU writer (``to_geotiff(gpu=False, ...)``);
7+
the GPU writer's invocation of ``_validate_crs_fallback`` at
8+
``_writers/gpu.py:507`` and the dispatcher thread-through at
9+
``_writers/eager.py:447`` have no targeted tests.
10+
11+
A regression dropping either call would let
12+
``write_geotiff_gpu(..., crs="EPSG:4326")`` on a host without pyproj,
13+
or any other unparseable CRS string, silently emit a garbage citation
14+
field that non-libgeotiff readers cannot interpret. The eager test
15+
catches the CPU path; this module closes the GPU and dispatcher gap.
16+
"""
17+
from __future__ import annotations
18+
19+
import importlib.util
20+
import os
21+
import warnings
22+
23+
import numpy as np
24+
import pytest
25+
import xarray as xr
26+
27+
28+
def _gpu_available() -> bool:
29+
if importlib.util.find_spec("cupy") is None:
30+
return False
31+
try:
32+
import cupy
33+
34+
return bool(cupy.cuda.is_available())
35+
except Exception:
36+
return False
37+
38+
39+
_HAS_GPU = _gpu_available()
40+
pytestmark = pytest.mark.skipif(
41+
not _HAS_GPU, reason="cupy + CUDA required",
42+
)
43+
44+
45+
def _make_gpu_da() -> xr.DataArray:
46+
"""Build a tiny CuPy-backed DataArray for the GPU writer."""
47+
import cupy
48+
49+
arr = cupy.asarray(np.arange(16, dtype=np.float32).reshape(4, 4))
50+
return xr.DataArray(
51+
arr,
52+
dims=("y", "x"),
53+
coords={"y": np.arange(4.0, 0, -1), "x": np.arange(4.0)},
54+
)
55+
56+
57+
def _make_cpu_da() -> xr.DataArray:
58+
"""Numpy-backed twin used to exercise the to_geotiff(gpu=True) path."""
59+
arr = np.arange(16, dtype=np.float32).reshape(4, 4)
60+
return xr.DataArray(
61+
arr,
62+
dims=("y", "x"),
63+
coords={"y": np.arange(4.0, 0, -1), "x": np.arange(4.0)},
64+
)
65+
66+
67+
class TestWriteGeotiffGpuFailClosed:
68+
"""``write_geotiff_gpu`` refuses to land an unvalidatable CRS by default."""
69+
70+
def test_garbage_string_kwarg_raises(self, tmp_path):
71+
"""A free-form non-WKT, non-PROJ string raises by default."""
72+
from xrspatial.geotiff import write_geotiff_gpu
73+
74+
out = str(tmp_path / "gpu_garbage_kwarg_1929.tif")
75+
with pytest.warns(Warning):
76+
with pytest.raises(ValueError, match="GTCitationGeoKey"):
77+
write_geotiff_gpu(
78+
_make_gpu_da(), out, crs="absolute-garbage")
79+
80+
def test_garbage_string_attr_raises(self, tmp_path):
81+
"""Same guard fires when garbage arrives via ``attrs['crs']``."""
82+
from xrspatial.geotiff import write_geotiff_gpu
83+
84+
out = str(tmp_path / "gpu_garbage_attr_1929.tif")
85+
da = _make_gpu_da()
86+
da.attrs["crs"] = "still-garbage"
87+
with pytest.warns(Warning):
88+
with pytest.raises(ValueError, match="GTCitationGeoKey"):
89+
write_geotiff_gpu(da, out)
90+
91+
def test_opt_in_allows_garbage(self, tmp_path):
92+
"""``allow_unparseable_crs=True`` restores the citation-only write."""
93+
from xrspatial.geotiff import write_geotiff_gpu
94+
95+
out = str(tmp_path / "gpu_optin_1929.tif")
96+
with pytest.warns(Warning):
97+
write_geotiff_gpu(
98+
_make_gpu_da(),
99+
out,
100+
crs="absolute-garbage",
101+
allow_unparseable_crs=True,
102+
)
103+
assert os.path.exists(out)
104+
105+
def test_message_recommends_alternatives(self, tmp_path):
106+
"""The error message points to the four recovery options."""
107+
from xrspatial.geotiff import write_geotiff_gpu
108+
109+
out = str(tmp_path / "gpu_msg_check_1929.tif")
110+
with pytest.warns(Warning):
111+
with pytest.raises(ValueError) as exc:
112+
write_geotiff_gpu(_make_gpu_da(), out, crs="bogus")
113+
msg = str(exc.value)
114+
assert "EPSG" in msg
115+
assert "WKT" in msg
116+
assert "allow_unparseable_crs" in msg
117+
118+
def test_epsg_int_unchanged(self, tmp_path):
119+
"""An int EPSG kwarg never reaches the fallback path."""
120+
from xrspatial.geotiff import write_geotiff_gpu
121+
122+
out = str(tmp_path / "gpu_epsg_int_1929.tif")
123+
write_geotiff_gpu(_make_gpu_da(), out, crs=4326)
124+
assert os.path.exists(out)
125+
126+
def test_valid_wkt_unchanged(self, tmp_path):
127+
"""A WKT-shaped string is accepted by the structural check."""
128+
from xrspatial.geotiff import write_geotiff_gpu
129+
130+
out = str(tmp_path / "gpu_wkt_shaped_1929.tif")
131+
wkt = (
132+
'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",'
133+
"6378137,298.257223563]],PRIMEM[\"Greenwich\",0],"
134+
"UNIT[\"degree\",0.0174532925199433]]"
135+
)
136+
write_geotiff_gpu(_make_gpu_da(), out, crs=wkt)
137+
assert os.path.exists(out)
138+
139+
def test_no_crs_at_all_unchanged(self, tmp_path):
140+
"""No CRS supplied means no GTCitationGeoKey; the validator is a no-op."""
141+
from xrspatial.geotiff import write_geotiff_gpu
142+
143+
out = str(tmp_path / "gpu_no_crs_1929.tif")
144+
write_geotiff_gpu(_make_gpu_da(), out)
145+
assert os.path.exists(out)
146+
147+
148+
class TestToGeotiffGpuDispatcherFailClosed:
149+
"""``to_geotiff(gpu=True)`` threads the validator through to the GPU writer.
150+
151+
The dispatcher branch at ``_writers/eager.py:447`` forwards
152+
``allow_unparseable_crs`` into ``write_geotiff_gpu``. A regression
153+
dropping the forward (e.g. an accidental kwarg-rename or a missed
154+
keyword in a refactor) would silently bypass the validator while
155+
looking like everything still works.
156+
"""
157+
158+
def test_dispatcher_garbage_raises_with_cupy_input(self, tmp_path):
159+
"""CuPy-backed input auto-routes to the GPU writer."""
160+
from xrspatial.geotiff import to_geotiff
161+
162+
out = str(tmp_path / "dispatcher_garbage_gpu_1929.tif")
163+
with pytest.warns(Warning):
164+
with pytest.raises(ValueError, match="GTCitationGeoKey"):
165+
to_geotiff(_make_gpu_da(), out, crs="absolute-garbage")
166+
167+
def test_dispatcher_gpu_kwarg_garbage_raises(self, tmp_path):
168+
"""Explicit ``gpu=True`` on numpy data also threads through."""
169+
from xrspatial.geotiff import to_geotiff
170+
171+
out = str(tmp_path / "dispatcher_gpu_kwarg_1929.tif")
172+
with pytest.warns(Warning):
173+
with pytest.raises(ValueError, match="GTCitationGeoKey"):
174+
to_geotiff(
175+
_make_cpu_da(), out, gpu=True, crs="absolute-garbage")
176+
177+
def test_dispatcher_opt_in_forwarded(self, tmp_path):
178+
"""``allow_unparseable_crs=True`` is forwarded into the GPU writer."""
179+
from xrspatial.geotiff import to_geotiff
180+
181+
out = str(tmp_path / "dispatcher_optin_gpu_1929.tif")
182+
with pytest.warns(Warning):
183+
to_geotiff(
184+
_make_gpu_da(),
185+
out,
186+
crs="absolute-garbage",
187+
allow_unparseable_crs=True,
188+
)
189+
assert os.path.exists(out)
190+
191+
def test_dispatcher_opt_in_explicit_gpu(self, tmp_path):
192+
"""``to_geotiff(gpu=True, allow_unparseable_crs=True)`` also works."""
193+
from xrspatial.geotiff import to_geotiff
194+
195+
out = str(tmp_path / "dispatcher_optin_gpu_explicit_1929.tif")
196+
with pytest.warns(Warning):
197+
to_geotiff(
198+
_make_cpu_da(),
199+
out,
200+
gpu=True,
201+
crs="absolute-garbage",
202+
allow_unparseable_crs=True,
203+
)
204+
assert os.path.exists(out)
205+
206+
207+
class TestErrorMessageParity:
208+
"""The GPU and eager error messages match for the same input.
209+
210+
A user catching ``ValueError`` from ``to_geotiff`` should see the
211+
same message whether the backend is CPU or GPU. A cross-backend
212+
drift would force callers to special-case the error string.
213+
"""
214+
215+
def test_gpu_vs_cpu_message_matches(self, tmp_path):
216+
from xrspatial.geotiff import to_geotiff, write_geotiff_gpu
217+
218+
out_cpu = str(tmp_path / "cpu_msg_1929.tif")
219+
out_gpu = str(tmp_path / "gpu_msg_1929.tif")
220+
221+
with warnings.catch_warnings():
222+
warnings.simplefilter("ignore")
223+
with pytest.raises(ValueError) as exc_cpu:
224+
to_geotiff(_make_cpu_da(), out_cpu, crs="absolute-garbage")
225+
with pytest.raises(ValueError) as exc_gpu:
226+
write_geotiff_gpu(
227+
_make_gpu_da(), out_gpu, crs="absolute-garbage")
228+
assert str(exc_cpu.value) == str(exc_gpu.value)
229+
230+
231+
class TestKwargDefaultParity:
232+
"""``allow_unparseable_crs`` defaults to False on every writer.
233+
234+
A drift in the default (e.g. one writer setting True for back-compat
235+
while another stays False) would silently re-open the #1929 hole on
236+
just one backend. Pin the canonical default explicitly.
237+
"""
238+
239+
def test_default_is_false_on_all_writers(self):
240+
import inspect
241+
242+
from xrspatial.geotiff import to_geotiff, write_geotiff_gpu
243+
244+
for fn in (to_geotiff, write_geotiff_gpu):
245+
sig = inspect.signature(fn)
246+
param = sig.parameters.get("allow_unparseable_crs")
247+
assert param is not None, (
248+
f"{fn.__name__} must accept allow_unparseable_crs"
249+
)
250+
assert param.default is False, (
251+
f"{fn.__name__}.allow_unparseable_crs default "
252+
f"drifted to {param.default!r}; #1929 requires fail-closed."
253+
)

0 commit comments

Comments
 (0)