diff --git a/xrspatial/geotiff/_backends/gpu.py b/xrspatial/geotiff/_backends/gpu.py index c84ea30b..616a27f7 100644 --- a/xrspatial/geotiff/_backends/gpu.py +++ b/xrspatial/geotiff/_backends/gpu.py @@ -210,10 +210,13 @@ def read_geotiff_gpu(source: str, *, stable_only : bool, default False [experimental] Read-side opt-in for stable-tier sources only. The GPU read path does not consume VRT sources directly (VRT - routing happens in ``open_geotiff``), so this kwarg is accepted - for cross-backend signature symmetry and is a no-op on the GPU - eager / chunked paths. See ``open_geotiff`` for the full - description. + routing happens in ``open_geotiff``), but it does route HTTP / + fsspec sources through the CPU fallback, and those advanced-tier + readers must be gated. With ``stable_only=True`` a remote source + raises ``RemoteStableSourcesOnlyError`` before any cupy import or + decode, matching ``open_geotiff`` and ``read_geotiff_dask``. Pass + ``allow_experimental_codecs=True`` to unlock the advanced tier. + See ``open_geotiff`` for the full description. allow_experimental_codecs : bool, default False [experimental] Read-side opt-in for Tier 3 experimental codecs (``lerc``, ``jpeg2000`` / ``j2k``, ``lz4``). The GPU read path @@ -275,6 +278,21 @@ def read_geotiff_gpu(source: str, *, max_cloud_bytes=max_cloud_bytes, ) + # ``open_geotiff`` and ``read_geotiff_dask`` gate ``stable_only=True`` + # for advanced-tier HTTP / fsspec sources before dispatching. This GPU + # entry point is also a direct public reader and it routes remote + # sources through the CPU fallback below, so a direct caller could read + # an advanced-tier remote source under ``stable_only=True`` unless the + # same gate fires here. Run it before the cupy import, the CUDA + # preflight, and the remote fallback so the rejection is independent of + # GPU availability, matching the other two readers. + from .._validation import _validate_stable_only_remote + _validate_stable_only_remote( + source, + stable_only=stable_only, + allow_experimental_codecs=allow_experimental_codecs, + ) + new_passed = on_gpu_failure is not _ON_GPU_FAILURE_SENTINEL old_passed = gpu is not _GPU_DEPRECATED_SENTINEL if new_passed and old_passed: diff --git a/xrspatial/geotiff/tests/test_stable_only_remote_2821.py b/xrspatial/geotiff/tests/test_stable_only_remote_2821.py index 32ba6ef2..09eb645f 100644 --- a/xrspatial/geotiff/tests/test_stable_only_remote_2821.py +++ b/xrspatial/geotiff/tests/test_stable_only_remote_2821.py @@ -22,12 +22,24 @@ import pytest from xrspatial.geotiff import (GeoTIFFAmbiguousMetadataError, RemoteStableSourcesOnlyError, - open_geotiff, read_geotiff_dask) + open_geotiff, read_geotiff_dask, read_geotiff_gpu) from xrspatial.geotiff.tests._helpers.tiff_builders import make_minimal_tiff fsspec = pytest.importorskip("fsspec") +def _gpu_available() -> bool: + try: + import cupy + return bool(cupy.cuda.is_available()) + except Exception: + return False + + +_HAS_GPU = _gpu_available() +_gpu_only = pytest.mark.skipif(not _HAS_GPU, reason="cupy + CUDA required") + + _MEMORY_URL = "memory:///stable_only_2821/sample.tif" @@ -134,3 +146,38 @@ def test_local_dask_source_succeeds_under_stable_only(local_tiff_path): """A plain local-file dask read still works under ``stable_only=True``.""" result = open_geotiff(local_tiff_path, stable_only=True, chunks=2) assert result.shape == (4, 4) + + +# --------------------------------------------------------------------------- +# Direct GPU entry point (issue #2867) +# +# ``read_geotiff_gpu`` is a public direct reader that routes HTTP / fsspec +# sources through the CPU fallback, so it must apply the same remote gate as +# ``open_geotiff`` and ``read_geotiff_dask``. The gate runs before the cupy +# import and the CUDA preflight, so the rejection tests run on a CPU-only +# machine -- no GPU required. Only the unlock test, which performs a real +# read, needs cupy + CUDA. +# --------------------------------------------------------------------------- + + +def test_gpu_direct_fsspec_source_rejected_under_stable_only(memory_tiff_url): + """The direct ``read_geotiff_gpu`` entry point gates remote sources too.""" + with pytest.raises(RemoteStableSourcesOnlyError) as excinfo: + read_geotiff_gpu(memory_tiff_url, stable_only=True) + _assert_remote_stable_error(excinfo) + + +def test_gpu_direct_http_source_rejected_under_stable_only(): + """An ``http://`` source is gated on the GPU path before any fetch.""" + with pytest.raises(RemoteStableSourcesOnlyError) as excinfo: + read_geotiff_gpu("http://example.invalid/sample.tif", stable_only=True) + _assert_remote_stable_error(excinfo) + + +@_gpu_only +def test_gpu_direct_fsspec_source_allowed_with_experimental_optin(memory_tiff_url): + """``allow_experimental_codecs=True`` unlocks the advanced tier on GPU.""" + result = read_geotiff_gpu( + memory_tiff_url, stable_only=True, allow_experimental_codecs=True, + ) + assert result.shape == (4, 4)