Skip to content

Commit d1ff2c7

Browse files
committed
geotiff: tighten CRS fail-closed GPU test parity (PR #1956 review)
- TestKwargDefaultParity now also checks _write_vrt_tiled, the third writer that consumes allow_unparseable_crs and routes through _validate_crs_fallback. - TestToGeotiffGpuDispatcherFailClosed installs a spy on the module-level write_geotiff_gpu symbol so each dispatcher test confirms the GPU path executed rather than silently falling back to the CPU writer on ImportError. - TestErrorMessageParity replaces the brittle full-string equality with a subset check over the recovery hints both backends share via _validate_crs_fallback.
1 parent 4a52fa8 commit d1ff2c7

1 file changed

Lines changed: 87 additions & 10 deletions

File tree

xrspatial/geotiff/tests/test_crs_fail_closed_gpu_1929.py

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -153,31 +153,65 @@ class TestToGeotiffGpuDispatcherFailClosed:
153153
dropping the forward (e.g. an accidental kwarg-rename or a missed
154154
keyword in a refactor) would silently bypass the validator while
155155
looking like everything still works.
156+
157+
The dispatcher also catches ``ImportError`` from ``write_geotiff_gpu``
158+
at ``_writers/eager.py:450`` and falls back to the CPU writer with a
159+
``GeoTIFFFallbackWarning``. ``pytest.warns(Warning)`` swallows that
160+
fallback warning, so a silent CPU fallback would let these tests pass
161+
while never executing the GPU path under audit. Each test installs a
162+
spy on the module-level ``write_geotiff_gpu`` symbol that the
163+
dispatcher calls, and asserts the spy fired.
156164
"""
157165

158-
def test_dispatcher_garbage_raises_with_cupy_input(self, tmp_path):
166+
@staticmethod
167+
def _install_gpu_spy(monkeypatch):
168+
"""Wrap ``write_geotiff_gpu`` so the dispatcher entry is recorded."""
169+
from xrspatial.geotiff._writers import eager as _eager
170+
171+
real = _eager.write_geotiff_gpu
172+
calls = []
173+
174+
def _spy(*args, **kwargs):
175+
calls.append((args, kwargs))
176+
return real(*args, **kwargs)
177+
178+
monkeypatch.setattr(_eager, "write_geotiff_gpu", _spy)
179+
return calls
180+
181+
def test_dispatcher_garbage_raises_with_cupy_input(
182+
self, tmp_path, monkeypatch):
159183
"""CuPy-backed input auto-routes to the GPU writer."""
160184
from xrspatial.geotiff import to_geotiff
161185

186+
calls = self._install_gpu_spy(monkeypatch)
162187
out = str(tmp_path / "dispatcher_garbage_gpu_1929.tif")
163188
with pytest.warns(Warning):
164189
with pytest.raises(ValueError, match="GTCitationGeoKey"):
165190
to_geotiff(_make_gpu_da(), out, crs="absolute-garbage")
191+
assert calls, (
192+
"dispatcher silently fell back to CPU; GPU path never entered"
193+
)
166194

167-
def test_dispatcher_gpu_kwarg_garbage_raises(self, tmp_path):
195+
def test_dispatcher_gpu_kwarg_garbage_raises(
196+
self, tmp_path, monkeypatch):
168197
"""Explicit ``gpu=True`` on numpy data also threads through."""
169198
from xrspatial.geotiff import to_geotiff
170199

200+
calls = self._install_gpu_spy(monkeypatch)
171201
out = str(tmp_path / "dispatcher_gpu_kwarg_1929.tif")
172202
with pytest.warns(Warning):
173203
with pytest.raises(ValueError, match="GTCitationGeoKey"):
174204
to_geotiff(
175205
_make_cpu_da(), out, gpu=True, crs="absolute-garbage")
206+
assert calls, (
207+
"dispatcher silently fell back to CPU; GPU path never entered"
208+
)
176209

177-
def test_dispatcher_opt_in_forwarded(self, tmp_path):
210+
def test_dispatcher_opt_in_forwarded(self, tmp_path, monkeypatch):
178211
"""``allow_unparseable_crs=True`` is forwarded into the GPU writer."""
179212
from xrspatial.geotiff import to_geotiff
180213

214+
calls = self._install_gpu_spy(monkeypatch)
181215
out = str(tmp_path / "dispatcher_optin_gpu_1929.tif")
182216
with pytest.warns(Warning):
183217
to_geotiff(
@@ -187,11 +221,17 @@ def test_dispatcher_opt_in_forwarded(self, tmp_path):
187221
allow_unparseable_crs=True,
188222
)
189223
assert os.path.exists(out)
224+
assert calls, (
225+
"dispatcher silently fell back to CPU; GPU path never entered"
226+
)
227+
# Confirm the kwarg actually reached the GPU writer.
228+
assert calls[0][1].get("allow_unparseable_crs") is True
190229

191-
def test_dispatcher_opt_in_explicit_gpu(self, tmp_path):
230+
def test_dispatcher_opt_in_explicit_gpu(self, tmp_path, monkeypatch):
192231
"""``to_geotiff(gpu=True, allow_unparseable_crs=True)`` also works."""
193232
from xrspatial.geotiff import to_geotiff
194233

234+
calls = self._install_gpu_spy(monkeypatch)
195235
out = str(tmp_path / "dispatcher_optin_gpu_explicit_1929.tif")
196236
with pytest.warns(Warning):
197237
to_geotiff(
@@ -202,14 +242,23 @@ def test_dispatcher_opt_in_explicit_gpu(self, tmp_path):
202242
allow_unparseable_crs=True,
203243
)
204244
assert os.path.exists(out)
245+
assert calls, (
246+
"dispatcher silently fell back to CPU; GPU path never entered"
247+
)
248+
assert calls[0][1].get("allow_unparseable_crs") is True
205249

206250

207251
class TestErrorMessageParity:
208-
"""The GPU and eager error messages match for the same input.
252+
"""The GPU and eager error messages share the recovery hints.
209253
210254
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.
255+
same recovery guidance whether the backend is CPU or GPU. A
256+
cross-backend drift would force callers to special-case the error
257+
string. Asserting equality on the full message is too brittle though
258+
(e.g. either backend may eventually prepend or append backend-specific
259+
context like the destination path or the offending kwarg name); pin
260+
the shared recovery substrings instead so the assertion fails for
261+
real drift but tolerates additive backend-specific context.
213262
"""
214263

215264
def test_gpu_vs_cpu_message_matches(self, tmp_path):
@@ -225,7 +274,29 @@ def test_gpu_vs_cpu_message_matches(self, tmp_path):
225274
with pytest.raises(ValueError) as exc_gpu:
226275
write_geotiff_gpu(
227276
_make_gpu_da(), out_gpu, crs="absolute-garbage")
228-
assert str(exc_cpu.value) == str(exc_gpu.value)
277+
278+
msg_cpu = str(exc_cpu.value)
279+
msg_gpu = str(exc_gpu.value)
280+
# Recovery hints emitted by ``_validate_crs_fallback`` for #1929.
281+
# Both backends route through the same helper, so each keyword
282+
# must appear in both messages.
283+
recovery_keywords = (
284+
"GTCitationGeoKey",
285+
"EPSG",
286+
"WKT",
287+
"pyproj",
288+
"allow_unparseable_crs",
289+
"absolute-garbage",
290+
)
291+
for token in recovery_keywords:
292+
assert token in msg_cpu, (
293+
f"CPU error message missing recovery keyword {token!r}: "
294+
f"{msg_cpu!r}"
295+
)
296+
assert token in msg_gpu, (
297+
f"GPU error message missing recovery keyword {token!r}: "
298+
f"{msg_gpu!r}"
299+
)
229300

230301

231302
class TestKwargDefaultParity:
@@ -240,8 +311,14 @@ def test_default_is_false_on_all_writers(self):
240311
import inspect
241312

242313
from xrspatial.geotiff import to_geotiff, write_geotiff_gpu
243-
244-
for fn in (to_geotiff, write_geotiff_gpu):
314+
# ``_write_vrt_tiled`` is the third writer that consumes
315+
# ``allow_unparseable_crs`` and routes through
316+
# ``_validate_crs_fallback`` (eager.py:858). It is module-private
317+
# so import it directly from the writers module rather than from
318+
# the public ``xrspatial.geotiff`` namespace.
319+
from xrspatial.geotiff._writers.eager import _write_vrt_tiled
320+
321+
for fn in (to_geotiff, write_geotiff_gpu, _write_vrt_tiled):
245322
sig = inspect.signature(fn)
246323
param = sig.parameters.get("allow_unparseable_crs")
247324
assert param is not None, (

0 commit comments

Comments
 (0)