|
| 1 | +"""Regression tests for issue #1615. |
| 2 | +
|
| 3 | +``open_geotiff(gpu=True)`` used to silently drop the ``on_gpu_failure`` |
| 4 | +kwarg: the dispatcher did not declare it and the GPU branch did not |
| 5 | +forward it to ``read_geotiff_gpu``. Callers that wanted strict GPU |
| 6 | +failure semantics had to bypass ``open_geotiff`` entirely and call |
| 7 | +``read_geotiff_gpu`` directly, defeating the dispatcher. |
| 8 | +
|
| 9 | +The fix: |
| 10 | +
|
| 11 | +* ``open_geotiff`` accepts ``on_gpu_failure`` and forwards it to |
| 12 | + ``read_geotiff_gpu`` when ``gpu=True``. |
| 13 | +* ``on_gpu_failure`` paired with ``gpu=False`` (or unset) raises |
| 14 | + ``ValueError`` so the kwarg cannot be silently ignored. |
| 15 | +* The default sentinel keeps the dispatcher's behavior bit-stable for |
| 16 | + callers that never set ``on_gpu_failure``: ``read_geotiff_gpu`` is |
| 17 | + called without the kwarg, taking its own ``'auto'`` default. |
| 18 | +""" |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +import importlib.util |
| 22 | + |
| 23 | +import numpy as np |
| 24 | +import pytest |
| 25 | +import xarray as xr |
| 26 | + |
| 27 | + |
| 28 | +def _gpu_available() -> bool: |
| 29 | + """True if cupy is importable and CUDA is initialized.""" |
| 30 | + if importlib.util.find_spec("cupy") is None: |
| 31 | + return False |
| 32 | + try: |
| 33 | + import cupy |
| 34 | + return bool(cupy.cuda.is_available()) |
| 35 | + except Exception: |
| 36 | + return False |
| 37 | + |
| 38 | + |
| 39 | +_HAS_GPU = _gpu_available() |
| 40 | +_gpu_only = pytest.mark.skipif( |
| 41 | + not _HAS_GPU, |
| 42 | + reason="cupy + CUDA required", |
| 43 | +) |
| 44 | + |
| 45 | + |
| 46 | +@pytest.fixture |
| 47 | +def small_tiff_path(tmp_path): |
| 48 | + """4x6 single-band tiled tiff usable by both CPU and GPU readers.""" |
| 49 | + from xrspatial.geotiff import to_geotiff |
| 50 | + |
| 51 | + arr = np.arange(24, dtype=np.float32).reshape(4, 6) |
| 52 | + da = xr.DataArray( |
| 53 | + arr, |
| 54 | + dims=['y', 'x'], |
| 55 | + coords={ |
| 56 | + 'y': np.array([0.5, 1.5, 2.5, 3.5]), |
| 57 | + 'x': np.array([0.5, 1.5, 2.5, 3.5, 4.5, 5.5]), |
| 58 | + }, |
| 59 | + attrs={'crs': 4326}, |
| 60 | + ) |
| 61 | + p = tmp_path / 'on_gpu_failure_1615.tif' |
| 62 | + to_geotiff(da, str(p), tile_size=4) |
| 63 | + return str(p), arr |
| 64 | + |
| 65 | + |
| 66 | +# -------------------------------------------------------------------- |
| 67 | +# Signature: ``open_geotiff`` accepts ``on_gpu_failure``. |
| 68 | +# -------------------------------------------------------------------- |
| 69 | + |
| 70 | + |
| 71 | +def test_open_geotiff_signature_includes_on_gpu_failure(): |
| 72 | + """Direct ``inspect.signature`` check on the public dispatcher.""" |
| 73 | + import inspect |
| 74 | + |
| 75 | + from xrspatial.geotiff import open_geotiff |
| 76 | + |
| 77 | + sig = inspect.signature(open_geotiff) |
| 78 | + assert 'on_gpu_failure' in sig.parameters, ( |
| 79 | + "open_geotiff must declare on_gpu_failure so the GPU policy is " |
| 80 | + "addressable through the dispatcher entry point" |
| 81 | + ) |
| 82 | + |
| 83 | + |
| 84 | +# -------------------------------------------------------------------- |
| 85 | +# ``on_gpu_failure`` with ``gpu=False`` rejects up front. |
| 86 | +# -------------------------------------------------------------------- |
| 87 | + |
| 88 | + |
| 89 | +def test_on_gpu_failure_with_gpu_false_raises_value_error(small_tiff_path): |
| 90 | + """Refuse rather than silently dropping the kwarg on the CPU path.""" |
| 91 | + from xrspatial.geotiff import open_geotiff |
| 92 | + |
| 93 | + path, _ = small_tiff_path |
| 94 | + with pytest.raises(ValueError, match="on_gpu_failure only applies"): |
| 95 | + open_geotiff(path, on_gpu_failure='strict') |
| 96 | + |
| 97 | + |
| 98 | +def test_on_gpu_failure_with_explicit_gpu_false_raises(small_tiff_path): |
| 99 | + """``gpu=False`` explicitly is rejected just like the default.""" |
| 100 | + from xrspatial.geotiff import open_geotiff |
| 101 | + |
| 102 | + path, _ = small_tiff_path |
| 103 | + with pytest.raises(ValueError, match="on_gpu_failure only applies"): |
| 104 | + open_geotiff(path, gpu=False, on_gpu_failure='auto') |
| 105 | + |
| 106 | + |
| 107 | +def test_on_gpu_failure_with_chunks_only_raises(small_tiff_path): |
| 108 | + """Dask CPU path is not GPU and should refuse the kwarg.""" |
| 109 | + from xrspatial.geotiff import open_geotiff |
| 110 | + |
| 111 | + path, _ = small_tiff_path |
| 112 | + with pytest.raises(ValueError, match="on_gpu_failure only applies"): |
| 113 | + open_geotiff(path, chunks=2, on_gpu_failure='auto') |
| 114 | + |
| 115 | + |
| 116 | +# -------------------------------------------------------------------- |
| 117 | +# Default sentinel does not change CPU/dask behavior. |
| 118 | +# -------------------------------------------------------------------- |
| 119 | + |
| 120 | + |
| 121 | +def test_default_dispatch_unchanged_cpu(small_tiff_path): |
| 122 | + """Not passing ``on_gpu_failure`` keeps CPU dispatch byte-stable.""" |
| 123 | + from xrspatial.geotiff import open_geotiff |
| 124 | + |
| 125 | + path, arr = small_tiff_path |
| 126 | + da = open_geotiff(path) |
| 127 | + np.testing.assert_array_equal(da.values, arr) |
| 128 | + |
| 129 | + |
| 130 | +def test_default_dispatch_unchanged_dask(small_tiff_path): |
| 131 | + """Not passing ``on_gpu_failure`` keeps Dask CPU dispatch byte-stable.""" |
| 132 | + from xrspatial.geotiff import open_geotiff |
| 133 | + |
| 134 | + path, arr = small_tiff_path |
| 135 | + da = open_geotiff(path, chunks=2) |
| 136 | + np.testing.assert_array_equal(da.values, arr) |
| 137 | + |
| 138 | + |
| 139 | +# -------------------------------------------------------------------- |
| 140 | +# GPU forwarding: real behavior parity with ``read_geotiff_gpu``. |
| 141 | +# -------------------------------------------------------------------- |
| 142 | + |
| 143 | + |
| 144 | +@_gpu_only |
| 145 | +def test_open_geotiff_gpu_forwards_on_gpu_failure_auto(small_tiff_path): |
| 146 | + """``open_geotiff(gpu=True, on_gpu_failure='auto')`` works end-to-end.""" |
| 147 | + from xrspatial.geotiff import open_geotiff |
| 148 | + |
| 149 | + path, arr = small_tiff_path |
| 150 | + da = open_geotiff(path, gpu=True, on_gpu_failure='auto') |
| 151 | + # CuPy-backed DataArray -- .data.get() pulls back to host for comparison. |
| 152 | + np.testing.assert_array_equal(da.data.get(), arr) |
| 153 | + |
| 154 | + |
| 155 | +@_gpu_only |
| 156 | +def test_open_geotiff_gpu_forwards_on_gpu_failure_strict(small_tiff_path): |
| 157 | + """``on_gpu_failure='strict'`` also reaches the GPU pipeline.""" |
| 158 | + from xrspatial.geotiff import open_geotiff |
| 159 | + |
| 160 | + path, arr = small_tiff_path |
| 161 | + da = open_geotiff(path, gpu=True, on_gpu_failure='strict') |
| 162 | + np.testing.assert_array_equal(da.data.get(), arr) |
| 163 | + |
| 164 | + |
| 165 | +@_gpu_only |
| 166 | +def test_open_geotiff_gpu_rejects_invalid_on_gpu_failure(small_tiff_path): |
| 167 | + """An invalid value still surfaces from the underlying validator.""" |
| 168 | + from xrspatial.geotiff import open_geotiff |
| 169 | + |
| 170 | + path, _ = small_tiff_path |
| 171 | + with pytest.raises(ValueError, match="on_gpu_failure must be"): |
| 172 | + open_geotiff(path, gpu=True, on_gpu_failure='loose') |
| 173 | + |
| 174 | + |
| 175 | +# -------------------------------------------------------------------- |
| 176 | +# Static-only check that works on CPU CI: passing an invalid value |
| 177 | +# with gpu=True still routes through to read_geotiff_gpu's validator. |
| 178 | +# -------------------------------------------------------------------- |
| 179 | + |
| 180 | + |
| 181 | +def test_invalid_on_gpu_failure_reaches_gpu_validator_on_cpu(small_tiff_path): |
| 182 | + """Even on a CPU-only host, the kwarg should reach ``read_geotiff_gpu``. |
| 183 | +
|
| 184 | + Without GPU hardware, ``read_geotiff_gpu`` raises ``ImportError`` (no |
| 185 | + cupy) or runs through to the actual decode. The kwarg validator runs |
| 186 | + *before* the cupy import, so an invalid value surfaces deterministically |
| 187 | + in both environments. This pins the forwarding wire even on CPU-only CI. |
| 188 | + """ |
| 189 | + from xrspatial.geotiff import open_geotiff |
| 190 | + |
| 191 | + path, _ = small_tiff_path |
| 192 | + # gpu=True + invalid value: validation fires before any cupy import, |
| 193 | + # so the ValueError reaches us on every host. |
| 194 | + with pytest.raises(ValueError, match="on_gpu_failure must be"): |
| 195 | + open_geotiff(path, gpu=True, on_gpu_failure='loose') |
0 commit comments