|
| 1 | +"""Backend / parameter coverage for the VRT read path. |
| 2 | +
|
| 3 | +The non-VRT read backends (``open_geotiff`` / ``read_geotiff_dask`` / |
| 4 | +``read_geotiff_gpu``) all have dedicated multi-backend coverage; the |
| 5 | +VRT route through ``read_vrt`` historically lacked it. The eager |
| 6 | +numpy path has dense coverage, but the GPU and dask+GPU paths the |
| 7 | +``read_vrt`` body explicitly handles (the ``if gpu: cupy.asarray`` |
| 8 | +and trailing ``result.chunk(...)`` blocks) were only reachable |
| 9 | +indirectly via ``open_geotiff('.vrt', gpu=True)`` / ``..., chunks=N)`` |
| 10 | +and went untested. |
| 11 | +
|
| 12 | +The error-rejection branches for file-like sources combined with |
| 13 | +``gpu=True`` / ``chunks=N`` on ``open_geotiff`` were likewise covered |
| 14 | +only by inspection. |
| 15 | +
|
| 16 | +Test coverage gap sweep 2026-05-11 (pass 3): close the VRT backend |
| 17 | +coverage gap and the file-like-rejection parameter gaps. |
| 18 | +""" |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +import importlib.util |
| 22 | +import io |
| 23 | +import os |
| 24 | + |
| 25 | +import numpy as np |
| 26 | +import pytest |
| 27 | +import xarray as xr |
| 28 | + |
| 29 | +from xrspatial.geotiff import open_geotiff, read_vrt, to_geotiff |
| 30 | +from xrspatial.geotiff._vrt import write_vrt as _write_vrt_internal |
| 31 | + |
| 32 | + |
| 33 | +# --------------------------------------------------------------------------- |
| 34 | +# CUDA gating: GPU tests share a skip predicate with the rest of the |
| 35 | +# geotiff test suite. The token "cuda-unavailable" in the state CSV |
| 36 | +# notes column flags a re-run on a GPU host when a non-CUDA sweep |
| 37 | +# added these tests. |
| 38 | +# --------------------------------------------------------------------------- |
| 39 | + |
| 40 | +def _cuda_available() -> bool: |
| 41 | + if importlib.util.find_spec("cupy") is None: |
| 42 | + return False |
| 43 | + try: |
| 44 | + import cupy |
| 45 | + return bool(cupy.cuda.is_available()) |
| 46 | + except Exception: |
| 47 | + return False |
| 48 | + |
| 49 | + |
| 50 | +_HAS_CUDA = _cuda_available() |
| 51 | +_gpu_only = pytest.mark.skipif(not _HAS_CUDA, reason="cupy + CUDA required") |
| 52 | + |
| 53 | + |
| 54 | +# --------------------------------------------------------------------------- |
| 55 | +# Fixtures |
| 56 | +# --------------------------------------------------------------------------- |
| 57 | + |
| 58 | +@pytest.fixture |
| 59 | +def single_tile_vrt(tmp_path): |
| 60 | + """A trivial single-tile VRT plus its source array. |
| 61 | +
|
| 62 | + Float32 source so the VRT band advertises Float32 and the eager |
| 63 | + numpy read returns float32 (lets dtype-cast tests assert a real |
| 64 | + type change). |
| 65 | + """ |
| 66 | + arr = np.arange(16, dtype=np.float32).reshape(4, 4) |
| 67 | + tile_path = str(tmp_path / 'tile.tif') |
| 68 | + to_geotiff(arr, tile_path) |
| 69 | + vrt_path = str(tmp_path / 'mosaic.vrt') |
| 70 | + _write_vrt_internal(vrt_path, [tile_path]) |
| 71 | + return vrt_path, arr |
| 72 | + |
| 73 | + |
| 74 | +# --------------------------------------------------------------------------- |
| 75 | +# Cat 1: read_vrt backend coverage (GPU + dask+GPU) |
| 76 | +# --------------------------------------------------------------------------- |
| 77 | + |
| 78 | +@_gpu_only |
| 79 | +class TestReadVrtGpuBackend: |
| 80 | + """``read_vrt(gpu=True)`` returns a CuPy-backed DataArray. |
| 81 | +
|
| 82 | + The eager VRT decode runs on the CPU (the VRT internal reader |
| 83 | + walks SimpleSources and assembles via windowed reads) then the |
| 84 | + final ``if gpu: arr = cupy.asarray(arr)`` block transfers to GPU. |
| 85 | + A regression that dropped the transfer block would have shipped |
| 86 | + a numpy DataArray instead of a CuPy one; this test pins that. |
| 87 | + """ |
| 88 | + |
| 89 | + def test_read_vrt_gpu_returns_cupy(self, single_tile_vrt): |
| 90 | + import cupy |
| 91 | + |
| 92 | + vrt_path, arr = single_tile_vrt |
| 93 | + da = read_vrt(vrt_path, gpu=True) |
| 94 | + assert isinstance(da.data, cupy.ndarray), ( |
| 95 | + f"expected cupy.ndarray, got {type(da.data).__name__}" |
| 96 | + ) |
| 97 | + np.testing.assert_array_equal(da.data.get(), arr) |
| 98 | + |
| 99 | + def test_read_vrt_gpu_chunks_returns_dask_cupy(self, single_tile_vrt): |
| 100 | + """``read_vrt(gpu=True, chunks=N)`` is the documented dask+cupy |
| 101 | + VRT entry point. The trailing ``result.chunk(...)`` block has |
| 102 | + to wrap the cupy backing without falling back to numpy. |
| 103 | + """ |
| 104 | + import cupy |
| 105 | + import dask.array as da_mod |
| 106 | + |
| 107 | + vrt_path, arr = single_tile_vrt |
| 108 | + result = read_vrt(vrt_path, gpu=True, chunks=2) |
| 109 | + |
| 110 | + assert isinstance(result.data, da_mod.Array), ( |
| 111 | + f"expected dask Array, got {type(result.data).__name__}" |
| 112 | + ) |
| 113 | + # _meta tells distributed Dask the underlying array is cupy. |
| 114 | + # A numpy meta here would cause optimizers to silently move |
| 115 | + # data back to host. |
| 116 | + assert isinstance(result.data._meta, cupy.ndarray), ( |
| 117 | + f"expected cupy._meta, got " |
| 118 | + f"{type(result.data._meta).__module__}." |
| 119 | + f"{type(result.data._meta).__name__}" |
| 120 | + ) |
| 121 | + # Chunks honour the spatial spec; the band axis (absent here) |
| 122 | + # would chunk as a single block. |
| 123 | + assert result.data.chunks == ((2, 2), (2, 2)) |
| 124 | + |
| 125 | + computed = result.compute() |
| 126 | + assert isinstance(computed.data, cupy.ndarray) |
| 127 | + np.testing.assert_array_equal(computed.data.get(), arr) |
| 128 | + |
| 129 | + def test_open_geotiff_vrt_gpu_routes_through(self, single_tile_vrt): |
| 130 | + """``open_geotiff('.vrt', gpu=True)`` dispatches to ``read_vrt`` |
| 131 | + and surfaces the cupy data unchanged. The dispatcher branch |
| 132 | + is one line in ``open_geotiff`` but a refactor that dropped |
| 133 | + ``gpu=gpu`` from the forwarded kwargs would silently produce |
| 134 | + a numpy DataArray. |
| 135 | + """ |
| 136 | + import cupy |
| 137 | + |
| 138 | + vrt_path, arr = single_tile_vrt |
| 139 | + da = open_geotiff(vrt_path, gpu=True) |
| 140 | + assert isinstance(da.data, cupy.ndarray) |
| 141 | + np.testing.assert_array_equal(da.data.get(), arr) |
| 142 | + |
| 143 | + def test_open_geotiff_vrt_gpu_chunks(self, single_tile_vrt): |
| 144 | + """``open_geotiff('.vrt', gpu=True, chunks=N)`` is the combined |
| 145 | + dask+cupy entry point. Same dispatch test as the gpu-only |
| 146 | + variant but also pins the chunk forwarding. |
| 147 | + """ |
| 148 | + import cupy |
| 149 | + import dask.array as da_mod |
| 150 | + |
| 151 | + vrt_path, arr = single_tile_vrt |
| 152 | + result = open_geotiff(vrt_path, gpu=True, chunks=2) |
| 153 | + |
| 154 | + assert isinstance(result.data, da_mod.Array) |
| 155 | + assert isinstance(result.data._meta, cupy.ndarray) |
| 156 | + assert result.data.chunks == ((2, 2), (2, 2)) |
| 157 | + |
| 158 | + computed = result.compute() |
| 159 | + np.testing.assert_array_equal(computed.data.get(), arr) |
| 160 | + |
| 161 | + |
| 162 | +# --------------------------------------------------------------------------- |
| 163 | +# Cat 4: read_vrt parameter coverage (dtype / name) |
| 164 | +# --------------------------------------------------------------------------- |
| 165 | + |
| 166 | +class TestReadVrtDtypeKwarg: |
| 167 | + """``read_vrt(dtype=...)`` casts after decode and validates the cast.""" |
| 168 | + |
| 169 | + def test_safe_widening_cast(self, single_tile_vrt): |
| 170 | + """float32 -> float64 is permitted; values survive bit-for-bit.""" |
| 171 | + vrt_path, arr = single_tile_vrt |
| 172 | + da = read_vrt(vrt_path, dtype='float64') |
| 173 | + assert da.dtype == np.float64 |
| 174 | + np.testing.assert_array_equal(da.values, arr.astype(np.float64)) |
| 175 | + |
| 176 | + def test_float_to_int_rejected(self, single_tile_vrt): |
| 177 | + """Float-to-int is lossy and refused with a descriptive error. |
| 178 | + Mirrors ``open_geotiff(dtype=...)`` behaviour so callers see the |
| 179 | + same gate on both entry points. |
| 180 | + """ |
| 181 | + vrt_path, _ = single_tile_vrt |
| 182 | + with pytest.raises(ValueError, match="Cannot cast float"): |
| 183 | + read_vrt(vrt_path, dtype='int32') |
| 184 | + |
| 185 | + |
| 186 | +class TestReadVrtNameKwarg: |
| 187 | + """``read_vrt(name='custom')`` overrides the file-stem derivation.""" |
| 188 | + |
| 189 | + def test_explicit_name_used(self, single_tile_vrt): |
| 190 | + vrt_path, _ = single_tile_vrt |
| 191 | + da = read_vrt(vrt_path, name='custom_name') |
| 192 | + assert da.name == 'custom_name' |
| 193 | + |
| 194 | + def test_default_name_from_stem(self, single_tile_vrt): |
| 195 | + vrt_path, _ = single_tile_vrt |
| 196 | + da = read_vrt(vrt_path) |
| 197 | + # mosaic.vrt -> mosaic |
| 198 | + assert da.name == os.path.splitext(os.path.basename(vrt_path))[0] |
| 199 | + |
| 200 | + |
| 201 | +# --------------------------------------------------------------------------- |
| 202 | +# Cat 4: open_geotiff file-like + backend kwarg rejection |
| 203 | +# --------------------------------------------------------------------------- |
| 204 | + |
| 205 | +class TestOpenGeotiffFileLikeKwargRejection: |
| 206 | + """File-like sources reject ``gpu=True`` and ``chunks=N`` up front. |
| 207 | +
|
| 208 | + The check sits in ``open_geotiff`` (not the underlying readers) |
| 209 | + because both downstream paths re-open the source by path from |
| 210 | + worker tasks. A buffer passed through would either raise deep |
| 211 | + inside dask graph construction or silently behave as if the |
| 212 | + buffer were a string path. |
| 213 | + """ |
| 214 | + |
| 215 | + @staticmethod |
| 216 | + def _buf_with_tiff(tmp_path): |
| 217 | + arr = np.zeros((4, 4), dtype=np.float32) |
| 218 | + path = str(tmp_path / 'src.tif') |
| 219 | + to_geotiff(arr, path) |
| 220 | + with open(path, 'rb') as fh: |
| 221 | + return io.BytesIO(fh.read()) |
| 222 | + |
| 223 | + def test_gpu_with_file_like_raises(self, tmp_path): |
| 224 | + buf = self._buf_with_tiff(tmp_path) |
| 225 | + with pytest.raises(ValueError, match="gpu=True is not supported"): |
| 226 | + open_geotiff(buf, gpu=True) |
| 227 | + |
| 228 | + def test_chunks_with_file_like_raises(self, tmp_path): |
| 229 | + buf = self._buf_with_tiff(tmp_path) |
| 230 | + with pytest.raises(ValueError, match="chunks=.*file-like"): |
| 231 | + open_geotiff(buf, chunks=64) |
| 232 | + |
| 233 | + def test_chunks_with_pathlib_path_still_works(self, tmp_path): |
| 234 | + """Sanity-check: pathlib.Path is not file-like and must keep |
| 235 | + working through the dask path. Otherwise the file-like gate |
| 236 | + would also lock out Path inputs. |
| 237 | + """ |
| 238 | + arr = np.arange(16, dtype=np.float32).reshape(4, 4) |
| 239 | + path = tmp_path / 'sample.tif' |
| 240 | + to_geotiff(arr, str(path)) |
| 241 | + |
| 242 | + import dask.array as da_mod |
| 243 | + result = open_geotiff(path, chunks=2) |
| 244 | + assert isinstance(result.data, da_mod.Array) |
| 245 | + np.testing.assert_array_equal(np.asarray(result.data), arr) |
0 commit comments