Skip to content

Commit 55d6b1e

Browse files
committed
Add strict-mode regression tests (#1662)
21 tests covering the warn-vs-raise contract: - _geotiff_strict_mode() env var parsing (truthy + falsy values) - _wkt_to_epsg / _epsg_to_wkt default-warns vs strict-raises - VRT missing-source default-warns-then-continues vs strict-raises - _warn_or_raise_gpu_fallback default + strict for GPU helper sites - read_geotiff_gpu(on_gpu_failure='auto') honors XRSPATIAL_GEOTIFF_STRICT=1
1 parent 3940c18 commit 55d6b1e

2 files changed

Lines changed: 256 additions & 1 deletion

File tree

xrspatial/geotiff/_gpu_decode.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -972,7 +972,7 @@ def _try_kvikio_read_tiles(file_path, tile_offsets, tile_byte_counts, tile_bytes
972972
cupy.cuda.Device().synchronize()
973973
except Exception:
974974
pass
975-
_warn_or_raise_gpu_fallback("_gds_read_tiles", e)
975+
_warn_or_raise_gpu_fallback("_try_kvikio_read_tiles", e)
976976
return None
977977

978978

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
"""Regression tests for #1662: typed warnings + ``XRSPATIAL_GEOTIFF_STRICT``.
2+
3+
The audit in issue #1662 flagged ten ``except Exception: return None``
4+
sites in the geotiff module that silently swallowed errors. Each site
5+
now emits a ``GeoTIFFFallbackWarning`` (or re-raises in strict mode).
6+
This module pins the contract:
7+
8+
* Default mode: a fallback returns ``None`` (or skips) and a
9+
``GeoTIFFFallbackWarning`` is emitted with the original exception
10+
type and message.
11+
* ``XRSPATIAL_GEOTIFF_STRICT=1``: the same code paths re-raise the
12+
original exception. CI can flip the env var to fail loudly on any
13+
silent fallback.
14+
15+
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.
18+
"""
19+
from __future__ import annotations
20+
21+
import importlib.util
22+
import os
23+
import warnings
24+
25+
import pytest
26+
27+
from xrspatial.geotiff import (
28+
GeoTIFFFallbackWarning,
29+
_geotiff_strict_mode,
30+
_wkt_to_epsg,
31+
)
32+
from xrspatial.geotiff._geotags import _epsg_to_wkt
33+
34+
35+
@pytest.fixture
36+
def clear_strict_env(monkeypatch):
37+
"""Ensure the strict-mode env var is unset for the default-mode test."""
38+
monkeypatch.delenv('XRSPATIAL_GEOTIFF_STRICT', raising=False)
39+
40+
41+
@pytest.fixture
42+
def set_strict_env(monkeypatch):
43+
"""Set ``XRSPATIAL_GEOTIFF_STRICT=1`` for strict-mode tests."""
44+
monkeypatch.setenv('XRSPATIAL_GEOTIFF_STRICT', '1')
45+
46+
47+
# ---------------------------------------------------------------------------
48+
# _geotiff_strict_mode() helper
49+
# ---------------------------------------------------------------------------
50+
51+
def test_strict_mode_default_false(clear_strict_env):
52+
assert _geotiff_strict_mode() is False
53+
54+
55+
@pytest.mark.parametrize('value', ['1', 'true', 'True', 'yes', 'YES'])
56+
def test_strict_mode_truthy_values(monkeypatch, value):
57+
monkeypatch.setenv('XRSPATIAL_GEOTIFF_STRICT', value)
58+
assert _geotiff_strict_mode() is True
59+
60+
61+
@pytest.mark.parametrize('value', ['0', 'false', 'no', '', 'maybe'])
62+
def test_strict_mode_falsy_values(monkeypatch, value):
63+
monkeypatch.setenv('XRSPATIAL_GEOTIFF_STRICT', value)
64+
assert _geotiff_strict_mode() is False
65+
66+
67+
# ---------------------------------------------------------------------------
68+
# _wkt_to_epsg
69+
# ---------------------------------------------------------------------------
70+
71+
def test_wkt_to_epsg_default_warns_returns_none(clear_strict_env):
72+
"""In default mode a broken WKT input warns and returns None."""
73+
with warnings.catch_warnings(record=True) as w:
74+
warnings.simplefilter('always')
75+
result = _wkt_to_epsg('not-a-valid-wkt-string-1662')
76+
77+
assert result is None
78+
fallback_warnings = [
79+
x for x in w if issubclass(x.category, GeoTIFFFallbackWarning)
80+
]
81+
assert len(fallback_warnings) == 1
82+
assert '_wkt_to_epsg failed' in str(fallback_warnings[0].message)
83+
84+
85+
def test_wkt_to_epsg_strict_reraises(set_strict_env):
86+
"""Under XRSPATIAL_GEOTIFF_STRICT=1, _wkt_to_epsg re-raises."""
87+
with pytest.raises(Exception):
88+
_wkt_to_epsg('not-a-valid-wkt-string-1662')
89+
90+
91+
def test_wkt_to_epsg_valid_input_no_warning(clear_strict_env):
92+
"""A real WKT string returns its EPSG without any warning."""
93+
pyproj = pytest.importorskip('pyproj')
94+
wkt = pyproj.CRS.from_epsg(4326).to_wkt()
95+
with warnings.catch_warnings(record=True) as w:
96+
warnings.simplefilter('always')
97+
result = _wkt_to_epsg(wkt)
98+
99+
assert result == 4326
100+
fallback_warnings = [
101+
x for x in w if issubclass(x.category, GeoTIFFFallbackWarning)
102+
]
103+
assert fallback_warnings == []
104+
105+
106+
# ---------------------------------------------------------------------------
107+
# _epsg_to_wkt
108+
# ---------------------------------------------------------------------------
109+
110+
def test_epsg_to_wkt_default_warns_returns_none(clear_strict_env):
111+
"""An unknown EPSG warns and returns None in default mode."""
112+
pytest.importorskip('pyproj')
113+
with warnings.catch_warnings(record=True) as w:
114+
warnings.simplefilter('always')
115+
# EPSG 999999 is unassigned; pyproj raises CRSError.
116+
result = _epsg_to_wkt(999999)
117+
118+
assert result is None
119+
fallback_warnings = [
120+
x for x in w if issubclass(x.category, GeoTIFFFallbackWarning)
121+
]
122+
assert len(fallback_warnings) == 1
123+
assert '_epsg_to_wkt' in str(fallback_warnings[0].message)
124+
125+
126+
def test_epsg_to_wkt_strict_reraises(set_strict_env):
127+
"""Strict mode re-raises rather than warning."""
128+
pytest.importorskip('pyproj')
129+
with pytest.raises(Exception):
130+
_epsg_to_wkt(999999)
131+
132+
133+
# ---------------------------------------------------------------------------
134+
# VRT source skip
135+
# ---------------------------------------------------------------------------
136+
137+
def test_vrt_missing_source_default_warns_then_continues(
138+
clear_strict_env, tmp_path,
139+
):
140+
"""A VRT referencing a missing source file warns once and skips it."""
141+
from xrspatial.geotiff import read_vrt
142+
143+
vrt_path = tmp_path / 'mosaic_1662_missing.vrt'
144+
vrt_path.write_text(
145+
'<VRTDataset rasterXSize="4" rasterYSize="4">\n'
146+
' <SRS></SRS>\n'
147+
' <GeoTransform>0, 1, 0, 0, 0, -1</GeoTransform>\n'
148+
' <VRTRasterBand dataType="Float32" band="1">\n'
149+
' <NoDataValue>-9999</NoDataValue>\n'
150+
' <SimpleSource>\n'
151+
f' <SourceFilename relativeToVRT="0">{tmp_path}/does_not_exist_1662.tif</SourceFilename>\n'
152+
' <SourceBand>1</SourceBand>\n'
153+
' <SrcRect xOff="0" yOff="0" xSize="4" ySize="4"/>\n'
154+
' <DstRect xOff="0" yOff="0" xSize="4" ySize="4"/>\n'
155+
' </SimpleSource>\n'
156+
' </VRTRasterBand>\n'
157+
'</VRTDataset>\n'
158+
)
159+
160+
with warnings.catch_warnings(record=True) as w:
161+
warnings.simplefilter('always')
162+
da = read_vrt(str(vrt_path))
163+
164+
# The mosaic should still load (with a hole) and one warning should
165+
# describe the skipped source.
166+
assert da.shape == (4, 4)
167+
fallback_warnings = [
168+
x for x in w if issubclass(x.category, GeoTIFFFallbackWarning)
169+
]
170+
assert len(fallback_warnings) >= 1
171+
msgs = ' '.join(str(x.message) for x in fallback_warnings)
172+
assert 'VRT source' in msgs
173+
assert 'does_not_exist_1662' in msgs
174+
175+
176+
def test_vrt_missing_source_strict_raises(set_strict_env, tmp_path):
177+
"""In strict mode the missing source surfaces as an exception."""
178+
from xrspatial.geotiff import read_vrt
179+
180+
vrt_path = tmp_path / 'mosaic_1662_missing_strict.vrt'
181+
vrt_path.write_text(
182+
'<VRTDataset rasterXSize="4" rasterYSize="4">\n'
183+
' <SRS></SRS>\n'
184+
' <GeoTransform>0, 1, 0, 0, 0, -1</GeoTransform>\n'
185+
' <VRTRasterBand dataType="Float32" band="1">\n'
186+
' <NoDataValue>-9999</NoDataValue>\n'
187+
' <SimpleSource>\n'
188+
f' <SourceFilename relativeToVRT="0">{tmp_path}/does_not_exist_1662_strict.tif</SourceFilename>\n'
189+
' <SourceBand>1</SourceBand>\n'
190+
' <SrcRect xOff="0" yOff="0" xSize="4" ySize="4"/>\n'
191+
' <DstRect xOff="0" yOff="0" xSize="4" ySize="4"/>\n'
192+
' </SimpleSource>\n'
193+
' </VRTRasterBand>\n'
194+
'</VRTDataset>\n'
195+
)
196+
197+
with pytest.raises(Exception):
198+
read_vrt(str(vrt_path))
199+
200+
201+
# ---------------------------------------------------------------------------
202+
# _warn_or_raise_gpu_fallback pins the warn-vs-raise contract for every
203+
# GPU helper site flagged in #1662. Exercised directly so the test does
204+
# not depend on having a working CUDA/GDS/nvCOMP stack.
205+
# ---------------------------------------------------------------------------
206+
207+
CUPY_AVAILABLE = importlib.util.find_spec('cupy') is not None
208+
209+
210+
def test_warn_or_raise_gpu_fallback_default_warns(clear_strict_env):
211+
"""Default mode emits one GeoTIFFFallbackWarning carrying type + msg."""
212+
from xrspatial.geotiff._gpu_decode import _warn_or_raise_gpu_fallback
213+
214+
with warnings.catch_warnings(record=True) as w:
215+
warnings.simplefilter('always')
216+
_warn_or_raise_gpu_fallback(
217+
"_try_nvjpeg_batch_decode", RuntimeError("bogus 1662"))
218+
219+
fallback_warnings = [
220+
x for x in w if issubclass(x.category, GeoTIFFFallbackWarning)
221+
]
222+
assert len(fallback_warnings) == 1
223+
msg = str(fallback_warnings[0].message)
224+
assert '_try_nvjpeg_batch_decode' in msg
225+
assert 'RuntimeError' in msg
226+
assert 'bogus 1662' in msg
227+
228+
229+
def test_warn_or_raise_gpu_fallback_strict_reraises(set_strict_env):
230+
"""Strict mode re-raises the original exception."""
231+
from xrspatial.geotiff._gpu_decode import _warn_or_raise_gpu_fallback
232+
233+
with pytest.raises(RuntimeError, match='bogus 1662 strict'):
234+
_warn_or_raise_gpu_fallback(
235+
"_try_nvjpeg_batch_decode", RuntimeError("bogus 1662 strict"))
236+
237+
238+
# ---------------------------------------------------------------------------
239+
# read_geotiff_gpu on_gpu_failure='auto' + env var integration
240+
# ---------------------------------------------------------------------------
241+
242+
@pytest.mark.skipif(
243+
not CUPY_AVAILABLE,
244+
reason="cupy required for read_geotiff_gpu fallback test",
245+
)
246+
def test_read_geotiff_gpu_env_var_promotes_to_strict(set_strict_env, tmp_path):
247+
"""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)

0 commit comments

Comments
 (0)