Skip to content

Commit 3e69c08

Browse files
committed
Address PR #1667 review: __all__, traceback preservation, test strict promotion
Five Copilot review comments on PR #1667: 1. Export GeoTIFFFallbackWarning via __all__ so it surfaces in ``from xrspatial.geotiff import *`` and matches the docstring claim that it's a user-facing warning type. Update the public-API regression test in tests/test_features.py to keep the expected set in sync. 2. Preserve the original traceback when ``_warn_or_raise_gpu_fallback`` would raise in strict mode. The helper used to do ``raise exc`` from its own frame, which reset ``__traceback__`` to the helper. It now returns ``True`` in strict mode and each call site does a bare ``raise`` from its ``except`` block, keeping the original traceback intact. All nine call sites in ``_gpu_decode.py`` were updated. 3. Drop the unused ``import os`` from ``test_strict_mode_1662.py``. 4. Tighten the GPU gating in ``test_strict_mode_1662.py`` from a bare ``importlib.util.find_spec('cupy')`` check to a ``_gpu_available()`` helper that also confirms ``cupy.cuda.is_available()`` -- matches the pattern used in the other GPU-only geotiff tests. 5. Rewrite ``test_read_geotiff_gpu_env_var_promotes_to_strict`` so it actually exercises the env-var promotion. The previous version passed a non-existent path which raised regardless of strict mode. The new version writes a real on-disk TIF, monkeypatches ``gpu_decode_tiles_from_file`` and ``gpu_decode_tiles`` on the ``_gpu_decode`` module to raise, then asserts that the default mode returns a CuPy DataArray (CPU fallback path) and that ``XRSPATIAL_GEOTIFF_STRICT=1`` re-raises the patched error.
1 parent 7f6b18b commit 3e69c08

4 files changed

Lines changed: 157 additions & 41 deletions

File tree

xrspatial/geotiff/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
# is intentionally omitted: it is deprecated in favour of ``da.xrs.plot()``
5151
# and emits a ``DeprecationWarning`` when called.
5252
__all__ = [
53+
'GeoTIFFFallbackWarning',
5354
'open_geotiff',
5455
'read_geotiff_gpu',
5556
'read_geotiff_dask',

xrspatial/geotiff/_gpu_decode.py

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,28 @@
1313
from numba import cuda
1414

1515

16-
def _warn_or_raise_gpu_fallback(stage: str, exc: BaseException) -> None:
16+
def _warn_or_raise_gpu_fallback(stage: str, exc: BaseException) -> bool:
1717
"""Report a GPU helper falling back to None (issue #1662).
1818
19-
Under ``XRSPATIAL_GEOTIFF_STRICT=1`` the original exception is
20-
re-raised so CI catches silent fast-path regressions. In the default
21-
mode a ``GeoTIFFFallbackWarning`` is emitted with the exception type
22-
and message; the caller still receives ``None`` and chooses its own
23-
next step (typically another decoder or CPU fallback).
19+
Returns ``True`` when ``XRSPATIAL_GEOTIFF_STRICT=1`` is set; callers
20+
should then re-raise the live exception with a bare ``raise`` so the
21+
original traceback is preserved (re-raising ``exc`` here would reset
22+
``__traceback__`` to this helper's frame). Returns ``False`` in the
23+
default mode after emitting a ``GeoTIFFFallbackWarning`` with the
24+
exception type and message; the caller still returns ``None`` and
25+
chooses its own next step (typically another decoder or CPU
26+
fallback).
2427
"""
2528
from . import _geotiff_strict_mode, GeoTIFFFallbackWarning
2629
if _geotiff_strict_mode():
27-
raise exc
30+
return True
2831
warnings.warn(
2932
f"{stage} fell back to None "
3033
f"({type(exc).__name__}: {exc}).",
3134
GeoTIFFFallbackWarning,
3235
stacklevel=3,
3336
)
37+
return False
3438

3539
#: Fraction of free GPU memory we're willing to allocate in a single call.
3640
#: Above this, raise MemoryError up-front so the caller gets an actionable
@@ -972,7 +976,8 @@ def _try_kvikio_read_tiles(file_path, tile_offsets, tile_byte_counts, tile_bytes
972976
cupy.cuda.Device().synchronize()
973977
except Exception:
974978
pass
975-
_warn_or_raise_gpu_fallback("_try_kvikio_read_tiles", e)
979+
if _warn_or_raise_gpu_fallback("_try_kvikio_read_tiles", e):
980+
raise
976981
return None
977982

978983

@@ -1076,8 +1081,9 @@ def _try_nvcomp_batch_decompress(compressed_tiles, tile_bytes, compression):
10761081
d_decompressed = manager.decompress(d_compressed)
10771082
return cupy.concatenate([d.ravel() for d in d_decompressed])
10781083
except Exception as e:
1079-
_warn_or_raise_gpu_fallback(
1080-
"_try_nvcomp_batch_decompress (kvikio DeflateManager)", e)
1084+
stage = "_try_nvcomp_batch_decompress (kvikio DeflateManager)"
1085+
if _warn_or_raise_gpu_fallback(stage, e):
1086+
raise
10811087
return None
10821088

10831089
# Direct ctypes nvCOMP C API
@@ -1197,8 +1203,9 @@ class _NvcompDeflateDecompOpts(ctypes.Structure):
11971203
return d_decomp
11981204

11991205
except Exception as e:
1200-
_warn_or_raise_gpu_fallback(
1201-
"_try_nvcomp_batch_decompress (ctypes nvCOMP C API)", e)
1206+
stage = "_try_nvcomp_batch_decompress (ctypes nvCOMP C API)"
1207+
if _warn_or_raise_gpu_fallback(stage, e):
1208+
raise
12021209
return None
12031210

12041211

@@ -1376,7 +1383,8 @@ class _NvjpegImage(ctypes.Structure):
13761383
destroy_fn(nvjpeg_handle)
13771384

13781385
except Exception as e:
1379-
_warn_or_raise_gpu_fallback("_try_nvjpeg_batch_decode", e)
1386+
if _warn_or_raise_gpu_fallback("_try_nvjpeg_batch_decode", e):
1387+
raise
13801388
return None
13811389

13821390

@@ -1521,7 +1529,8 @@ class _NvjpegImage(ctypes.Structure):
15211529
destroy_fn(nvjpeg_handle)
15221530

15231531
except Exception as e:
1524-
_warn_or_raise_gpu_fallback("_nvjpeg_batch_encode", e)
1532+
if _warn_or_raise_gpu_fallback("_nvjpeg_batch_encode", e):
1533+
raise
15251534
return None
15261535

15271536

@@ -1680,7 +1689,8 @@ class _NvcompDecompOpts(ctypes.Structure):
16801689
except MemoryError:
16811690
raise
16821691
except Exception as e:
1683-
_warn_or_raise_gpu_fallback("_try_nvcomp_from_device_bufs", e)
1692+
if _warn_or_raise_gpu_fallback("_try_nvcomp_from_device_bufs", e):
1693+
raise
16841694
return None
16851695

16861696

@@ -2467,7 +2477,8 @@ class _DeflateCompOpts(ctypes.Structure):
24672477
return result
24682478

24692479
except Exception as e:
2470-
_warn_or_raise_gpu_fallback("_nvcomp_batch_compress", e)
2480+
if _warn_or_raise_gpu_fallback("_nvcomp_batch_compress", e):
2481+
raise
24712482
return None
24722483

24732484

@@ -2657,7 +2668,8 @@ class _NvJpeg2kImage(ctypes.Structure):
26572668
return d_all_tiles
26582669

26592670
except Exception as e:
2660-
_warn_or_raise_gpu_fallback("_try_nvjpeg2k_batch_decode", e)
2671+
if _warn_or_raise_gpu_fallback("_try_nvjpeg2k_batch_decode", e):
2672+
raise
26612673
return None
26622674

26632675

@@ -2803,7 +2815,8 @@ class _CompInfo(ctypes.Structure):
28032815
return result
28042816

28052817
except Exception as e:
2806-
_warn_or_raise_gpu_fallback("_nvjpeg2k_batch_encode", e)
2818+
if _warn_or_raise_gpu_fallback("_nvjpeg2k_batch_encode", e):
2819+
raise
28072820
return None
28082821

28092822

xrspatial/geotiff/tests/test_features.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2642,6 +2642,7 @@ def test_all_lists_supported_functions(self):
26422642
# public API. If any of these gets removed or renamed, that is a
26432643
# breaking change and should go through a deprecation cycle.
26442644
expected = {
2645+
'GeoTIFFFallbackWarning',
26452646
'open_geotiff',
26462647
'read_geotiff_gpu',
26472648
'read_geotiff_dask',

xrspatial/geotiff/tests/test_strict_mode_1662.py

Lines changed: 124 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,12 @@
1313
silent fallback.
1414
1515
The GPU helper sites in ``_gpu_decode.py`` are exercised indirectly
16-
through ``read_geotiff_gpu`` when CuPy is available; tests gated on
17-
``importlib.util.find_spec('cupy')`` are skipped otherwise.
16+
through ``read_geotiff_gpu`` when CuPy + CUDA are available; tests
17+
gated on ``_gpu_available()`` are skipped otherwise.
1818
"""
1919
from __future__ import annotations
2020

2121
import importlib.util
22-
import os
2322
import warnings
2423

2524
import pytest
@@ -141,14 +140,16 @@ def test_vrt_missing_source_default_warns_then_continues(
141140
from xrspatial.geotiff import read_vrt
142141

143142
vrt_path = tmp_path / 'mosaic_1662_missing.vrt'
143+
missing_src = f'{tmp_path}/does_not_exist_1662.tif'
144144
vrt_path.write_text(
145145
'<VRTDataset rasterXSize="4" rasterYSize="4">\n'
146146
' <SRS></SRS>\n'
147147
' <GeoTransform>0, 1, 0, 0, 0, -1</GeoTransform>\n'
148148
' <VRTRasterBand dataType="Float32" band="1">\n'
149149
' <NoDataValue>-9999</NoDataValue>\n'
150150
' <SimpleSource>\n'
151-
f' <SourceFilename relativeToVRT="0">{tmp_path}/does_not_exist_1662.tif</SourceFilename>\n'
151+
f' <SourceFilename relativeToVRT="0">{missing_src}'
152+
'</SourceFilename>\n'
152153
' <SourceBand>1</SourceBand>\n'
153154
' <SrcRect xOff="0" yOff="0" xSize="4" ySize="4"/>\n'
154155
' <DstRect xOff="0" yOff="0" xSize="4" ySize="4"/>\n'
@@ -178,14 +179,16 @@ def test_vrt_missing_source_strict_raises(set_strict_env, tmp_path):
178179
from xrspatial.geotiff import read_vrt
179180

180181
vrt_path = tmp_path / 'mosaic_1662_missing_strict.vrt'
182+
missing_src = f'{tmp_path}/does_not_exist_1662_strict.tif'
181183
vrt_path.write_text(
182184
'<VRTDataset rasterXSize="4" rasterYSize="4">\n'
183185
' <SRS></SRS>\n'
184186
' <GeoTransform>0, 1, 0, 0, 0, -1</GeoTransform>\n'
185187
' <VRTRasterBand dataType="Float32" band="1">\n'
186188
' <NoDataValue>-9999</NoDataValue>\n'
187189
' <SimpleSource>\n'
188-
f' <SourceFilename relativeToVRT="0">{tmp_path}/does_not_exist_1662_strict.tif</SourceFilename>\n'
190+
f' <SourceFilename relativeToVRT="0">{missing_src}'
191+
'</SourceFilename>\n'
189192
' <SourceBand>1</SourceBand>\n'
190193
' <SrcRect xOff="0" yOff="0" xSize="4" ySize="4"/>\n'
191194
' <DstRect xOff="0" yOff="0" xSize="4" ySize="4"/>\n'
@@ -204,18 +207,18 @@ def test_vrt_missing_source_strict_raises(set_strict_env, tmp_path):
204207
# not depend on having a working CUDA/GDS/nvCOMP stack.
205208
# ---------------------------------------------------------------------------
206209

207-
CUPY_AVAILABLE = importlib.util.find_spec('cupy') is not None
208-
209210

210211
def test_warn_or_raise_gpu_fallback_default_warns(clear_strict_env):
211212
"""Default mode emits one GeoTIFFFallbackWarning carrying type + msg."""
212213
from xrspatial.geotiff._gpu_decode import _warn_or_raise_gpu_fallback
213214

214215
with warnings.catch_warnings(record=True) as w:
215216
warnings.simplefilter('always')
216-
_warn_or_raise_gpu_fallback(
217+
result = _warn_or_raise_gpu_fallback(
217218
"_try_nvjpeg_batch_decode", RuntimeError("bogus 1662"))
218219

220+
# Default mode returns False so the caller falls back to None.
221+
assert result is False
219222
fallback_warnings = [
220223
x for x in w if issubclass(x.category, GeoTIFFFallbackWarning)
221224
]
@@ -226,30 +229,128 @@ def test_warn_or_raise_gpu_fallback_default_warns(clear_strict_env):
226229
assert 'bogus 1662' in msg
227230

228231

229-
def test_warn_or_raise_gpu_fallback_strict_reraises(set_strict_env):
230-
"""Strict mode re-raises the original exception."""
232+
def test_warn_or_raise_gpu_fallback_strict_returns_true(set_strict_env):
233+
"""Strict mode returns True so the caller can ``raise`` itself.
234+
235+
The helper deliberately does not raise here -- re-raising the
236+
captured exception from inside this frame would clobber the
237+
original traceback. Returning True lets each call site bubble the
238+
live exception up via a bare ``raise`` from its own ``except``
239+
block.
240+
"""
231241
from xrspatial.geotiff._gpu_decode import _warn_or_raise_gpu_fallback
232242

233-
with pytest.raises(RuntimeError, match='bogus 1662 strict'):
234-
_warn_or_raise_gpu_fallback(
243+
with warnings.catch_warnings(record=True) as w:
244+
warnings.simplefilter('always')
245+
result = _warn_or_raise_gpu_fallback(
235246
"_try_nvjpeg_batch_decode", RuntimeError("bogus 1662 strict"))
236247

248+
assert result is True
249+
# No warning should be emitted in strict mode.
250+
fallback_warnings = [
251+
x for x in w if issubclass(x.category, GeoTIFFFallbackWarning)
252+
]
253+
assert fallback_warnings == []
254+
255+
256+
def test_warn_or_raise_gpu_fallback_preserves_traceback(set_strict_env):
257+
"""The call-site pattern preserves the original exception traceback.
258+
259+
The helper returns True in strict mode; the caller re-raises with a
260+
bare ``raise``. The resulting traceback should point at the original
261+
failure (``raise RuntimeError(...)`` below), not at the helper.
262+
"""
263+
from xrspatial.geotiff._gpu_decode import _warn_or_raise_gpu_fallback
264+
265+
def site():
266+
try:
267+
raise RuntimeError("bogus 1662 traceback")
268+
except Exception as e:
269+
if _warn_or_raise_gpu_fallback("_dummy_stage", e):
270+
raise
271+
return None
272+
273+
with pytest.raises(RuntimeError, match='bogus 1662 traceback') as excinfo:
274+
site()
275+
# The deepest traceback frame should be ``site``'s ``raise
276+
# RuntimeError`` line, not ``_warn_or_raise_gpu_fallback``.
277+
tb = excinfo.tb
278+
while tb.tb_next is not None:
279+
tb = tb.tb_next
280+
assert tb.tb_frame.f_code.co_name == 'site'
281+
237282

238283
# ---------------------------------------------------------------------------
239284
# read_geotiff_gpu on_gpu_failure='auto' + env var integration
240285
# ---------------------------------------------------------------------------
241286

287+
def _gpu_available() -> bool:
288+
if importlib.util.find_spec("cupy") is None:
289+
return False
290+
try:
291+
import cupy
292+
return bool(cupy.cuda.is_available())
293+
except Exception:
294+
return False
295+
296+
297+
_HAS_GPU = _gpu_available()
298+
299+
242300
@pytest.mark.skipif(
243-
not CUPY_AVAILABLE,
244-
reason="cupy required for read_geotiff_gpu fallback test",
301+
not _HAS_GPU,
302+
reason="cupy + CUDA required for read_geotiff_gpu fallback test",
245303
)
246-
def test_read_geotiff_gpu_env_var_promotes_to_strict(set_strict_env, tmp_path):
304+
def test_read_geotiff_gpu_env_var_promotes_to_strict(monkeypatch, tmp_path):
247305
"""With on_gpu_failure='auto' but XRSPATIAL_GEOTIFF_STRICT=1, a GPU
248-
decode failure surfaces instead of falling back to CPU."""
249-
from xrspatial.geotiff import read_geotiff_gpu
250-
251-
# A non-existent path triggers a failure before any decode runs;
252-
# the env var should still bubble it up.
253-
bogus = str(tmp_path / 'no_such_file_1662_promote.tif')
254-
with pytest.raises(Exception):
255-
read_geotiff_gpu(bogus)
306+
decode failure surfaces instead of falling back to CPU.
307+
308+
The seam: ``read_geotiff_gpu`` does a local
309+
``from ._gpu_decode import gpu_decode_tiles_from_file`` inside the
310+
function body, so rebinding the attribute on the
311+
``xrspatial.geotiff._gpu_decode`` module is picked up on the next
312+
call. Stubbing it to raise lets us exercise both branches against
313+
a real on-disk TIF without needing a broken GPU stack.
314+
"""
315+
import cupy as cp
316+
import numpy as np
317+
import xarray as xr
318+
319+
from xrspatial.geotiff import read_geotiff_gpu, to_geotiff
320+
from xrspatial.geotiff import _gpu_decode
321+
322+
# 1. Write a small valid TIF so the metadata parse succeeds and we
323+
# reach the GPU decode stage.
324+
h, w = 16, 16
325+
arr = np.arange(h * w, dtype=np.float32).reshape(h, w)
326+
y = np.arange(h, dtype=np.float64) * -1.0 + 100.0
327+
x = np.arange(w, dtype=np.float64) * 1.0 + 0.0
328+
da = xr.DataArray(arr, dims=['y', 'x'], coords={'y': y, 'x': x})
329+
p = str(tmp_path / 'strict_promote_1662.tif')
330+
to_geotiff(da, p, crs=4326)
331+
332+
sentinel = "bogus 1662 promote"
333+
334+
def _boom(*args, **kwargs):
335+
raise RuntimeError(sentinel)
336+
337+
monkeypatch.setattr(
338+
_gpu_decode, 'gpu_decode_tiles_from_file', _boom)
339+
monkeypatch.setattr(
340+
_gpu_decode, 'gpu_decode_tiles', _boom)
341+
342+
# 2. Default mode: XRSPATIAL_GEOTIFF_STRICT unset, the failure
343+
# should be absorbed and the CPU fallback should return a
344+
# CuPy-backed DataArray.
345+
monkeypatch.delenv('XRSPATIAL_GEOTIFF_STRICT', raising=False)
346+
with warnings.catch_warnings():
347+
warnings.simplefilter('ignore')
348+
result = read_geotiff_gpu(p)
349+
assert isinstance(result, xr.DataArray)
350+
assert isinstance(result.data, cp.ndarray)
351+
352+
# 3. With XRSPATIAL_GEOTIFF_STRICT=1, the same call should re-raise
353+
# the patched RuntimeError instead of falling back.
354+
monkeypatch.setenv('XRSPATIAL_GEOTIFF_STRICT', '1')
355+
with pytest.raises(RuntimeError, match=sentinel):
356+
read_geotiff_gpu(p)

0 commit comments

Comments
 (0)