Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/source/reference/geotiff.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,24 @@ Writing
xrspatial.geotiff.to_geotiff
xrspatial.geotiff.write_geotiff_gpu
xrspatial.geotiff.write_vrt

Strict mode (``XRSPATIAL_GEOTIFF_STRICT``)
==========================================

Several internal helpers historically returned ``None`` when something went
wrong: pyproj failing to parse a WKT string, a VRT source file being
missing, a GPU helper (GDS, nvCOMP, nvJPEG, nvJPEG2000) hitting a CUDA or
library error. These now emit :class:`xrspatial.geotiff.GeoTIFFFallbackWarning`
with the original exception type and message.

Set ``XRSPATIAL_GEOTIFF_STRICT=1`` (or ``true``, ``yes``) to promote those
warnings into raised exceptions. The same env var also forces
``read_geotiff_gpu(on_gpu_failure='auto')`` to behave like
``on_gpu_failure='strict'`` so CI can fail loudly when the GPU fast path
silently falls back to CPU.

.. code-block:: bash

XRSPATIAL_GEOTIFF_STRICT=1 pytest xrspatial/geotiff/tests/

See issue #1662 for the audit and the full list of affected call sites.
45 changes: 40 additions & 5 deletions xrspatial/geotiff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from __future__ import annotations

import math
import os
import warnings
from typing import TYPE_CHECKING

Expand All @@ -49,6 +50,7 @@
# is intentionally omitted: it is deprecated in favour of ``da.xrs.plot()``
# and emits a ``DeprecationWarning`` when called.
__all__ = [
'GeoTIFFFallbackWarning',
'open_geotiff',
'read_geotiff_gpu',
'read_geotiff_dask',
Expand Down Expand Up @@ -79,17 +81,50 @@
_BAND_DIM_NAMES = ('band', 'bands', 'channel')


class GeoTIFFFallbackWarning(UserWarning):
"""Warning emitted when a geotiff helper falls back to a slower path.

Raised in the same call sites that would silently return ``None`` under
the historic ``except Exception: return None`` pattern. See issue #1662
for the audit and the ``XRSPATIAL_GEOTIFF_STRICT=1`` env var that
promotes these warnings to exceptions.
"""
Comment on lines +84 to +91


def _geotiff_strict_mode() -> bool:
"""Return True when ``XRSPATIAL_GEOTIFF_STRICT`` is set to a truthy value.

Strict mode promotes the silent fallbacks audited in issue #1662 into
raised exceptions. Useful in CI to catch GPU-path or VRT regressions
that would otherwise hide behind a CPU fallback or a missing tile.
"""
return os.environ.get(
'XRSPATIAL_GEOTIFF_STRICT', '').lower() in ('1', 'true', 'yes')


def _wkt_to_epsg(wkt_or_proj: str) -> int | None:
"""Try to extract an EPSG code from a WKT or PROJ string.

Returns None if pyproj is not installed or the string can't be parsed.

Under ``XRSPATIAL_GEOTIFF_STRICT=1`` the underlying exception is
re-raised instead of being swallowed. In the default mode a
``GeoTIFFFallbackWarning`` is emitted so callers can tell
pyproj-missing from pyproj-broken-input.
"""
try:
from pyproj import CRS
crs = CRS.from_user_input(wkt_or_proj)
epsg = crs.to_epsg()
return epsg
except Exception:
except Exception as e:
if _geotiff_strict_mode():
raise
warnings.warn(
f"_wkt_to_epsg failed ({type(e).__name__}: {e}); returning None.",
GeoTIFFFallbackWarning,
stacklevel=2,
)
return None


Expand Down Expand Up @@ -1975,7 +2010,7 @@ def _gpu_decode_single_band_tiles(
byte_order=byte_order,
)
except Exception as e:
if gpu == 'strict':
if gpu == 'strict' or _geotiff_strict_mode():
raise
warnings.warn(
f"read_geotiff_gpu: GPU decode failed "
Expand All @@ -1999,7 +2034,7 @@ def _gpu_decode_single_band_tiles(
byte_order=byte_order,
)
except Exception as e:
if gpu == 'strict':
if gpu == 'strict' or _geotiff_strict_mode():
raise
warnings.warn(
f"read_geotiff_gpu: GPU decode failed "
Expand Down Expand Up @@ -2596,7 +2631,7 @@ def _read_once():
masked_fill=masked_fill,
)
except Exception as e:
if gpu == 'strict':
if gpu == 'strict' or _geotiff_strict_mode():
raise
warnings.warn(
f"read_geotiff_gpu: GPU decode failed "
Expand Down Expand Up @@ -2628,7 +2663,7 @@ def _read_once():
masked_fill=masked_fill,
)
except Exception as e:
if gpu == 'strict':
if gpu == 'strict' or _geotiff_strict_mode():
raise
warnings.warn(
f"read_geotiff_gpu: GPU decode failed "
Expand Down
15 changes: 14 additions & 1 deletion xrspatial/geotiff/_geotags.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,11 +244,24 @@ def _epsg_to_wkt(epsg: int) -> str | None:
"""Resolve an EPSG code to a WKT string using pyproj.

Returns None if pyproj is not installed or the code is unknown.

Under ``XRSPATIAL_GEOTIFF_STRICT=1`` the underlying exception is
re-raised instead of being swallowed. See issue #1662.
"""
try:
from pyproj import CRS
return CRS.from_epsg(epsg).to_wkt()
except Exception:
except Exception as e:
import warnings
from . import _geotiff_strict_mode, GeoTIFFFallbackWarning
if _geotiff_strict_mode():
raise
warnings.warn(
f"_epsg_to_wkt({epsg!r}) failed "
f"({type(e).__name__}: {e}); returning None.",
GeoTIFFFallbackWarning,
stacklevel=2,
)
return None


Expand Down
69 changes: 58 additions & 11 deletions xrspatial/geotiff/_gpu_decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,35 @@
from __future__ import annotations

import math
import warnings

import numpy as np
from numba import cuda


def _warn_or_raise_gpu_fallback(stage: str, exc: BaseException) -> bool:
"""Report a GPU helper falling back to None (issue #1662).

Returns ``True`` when ``XRSPATIAL_GEOTIFF_STRICT=1`` is set; callers
should then re-raise the live exception with a bare ``raise`` so the
original traceback is preserved (re-raising ``exc`` here would reset
``__traceback__`` to this helper's frame). Returns ``False`` in the
default mode after emitting a ``GeoTIFFFallbackWarning`` with the
exception type and message; the caller still returns ``None`` and
chooses its own next step (typically another decoder or CPU
fallback).
"""
from . import _geotiff_strict_mode, GeoTIFFFallbackWarning
if _geotiff_strict_mode():
return True
warnings.warn(
f"{stage} fell back to None "
f"({type(exc).__name__}: {exc}).",
GeoTIFFFallbackWarning,
stacklevel=3,
)
return False

#: Fraction of free GPU memory we're willing to allocate in a single call.
#: Above this, raise MemoryError up-front so the caller gets an actionable
#: error rather than a CUDA OOM deep inside the kernel launch.
Expand Down Expand Up @@ -941,14 +966,18 @@ def _try_kvikio_read_tiles(file_path, tile_offsets, tile_byte_counts, tile_bytes
d_tiles.append(buf)
cupy.cuda.Device().synchronize()
return d_tiles
except Exception:
# GDS not available, version mismatch, or CUDA error
# Reset CUDA error state if possible
except Exception as e:
# GDS not available, version mismatch, or CUDA error.
# Reset CUDA error state if possible (the inner pass stays broad
# because a failed synchronize() during error recovery has no
# better recovery path; see issue #1662).
try:
import cupy
cupy.cuda.Device().synchronize()
except Exception:
pass
if _warn_or_raise_gpu_fallback("_try_kvikio_read_tiles", e):
raise
return None


Expand Down Expand Up @@ -1051,7 +1080,10 @@ def _try_nvcomp_batch_decompress(compressed_tiles, tile_bytes, compression):
]
d_decompressed = manager.decompress(d_compressed)
return cupy.concatenate([d.ravel() for d in d_decompressed])
except Exception:
except Exception as e:
stage = "_try_nvcomp_batch_decompress (kvikio DeflateManager)"
if _warn_or_raise_gpu_fallback(stage, e):
raise
return None

# Direct ctypes nvCOMP C API
Expand Down Expand Up @@ -1170,7 +1202,10 @@ class _NvcompDeflateDecompOpts(ctypes.Structure):

return d_decomp

except Exception:
except Exception as e:
stage = "_try_nvcomp_batch_decompress (ctypes nvCOMP C API)"
if _warn_or_raise_gpu_fallback(stage, e):
raise
return None


Expand Down Expand Up @@ -1347,7 +1382,9 @@ class _NvjpegImage(ctypes.Structure):
if destroy_fn is not None:
destroy_fn(nvjpeg_handle)

except Exception:
except Exception as e:
if _warn_or_raise_gpu_fallback("_try_nvjpeg_batch_decode", e):
raise
return None


Expand Down Expand Up @@ -1491,7 +1528,9 @@ class _NvjpegImage(ctypes.Structure):
if destroy_fn is not None:
destroy_fn(nvjpeg_handle)

except Exception:
except Exception as e:
if _warn_or_raise_gpu_fallback("_nvjpeg_batch_encode", e):
raise
return None


Expand Down Expand Up @@ -1649,7 +1688,9 @@ class _NvcompDecompOpts(ctypes.Structure):
return d_decomp
except MemoryError:
raise
except Exception:
except Exception as e:
if _warn_or_raise_gpu_fallback("_try_nvcomp_from_device_bufs", e):
raise
return None


Expand Down Expand Up @@ -2435,7 +2476,9 @@ class _DeflateCompOpts(ctypes.Structure):

return result

except Exception:
except Exception as e:
if _warn_or_raise_gpu_fallback("_nvcomp_batch_compress", e):
raise
return None


Expand Down Expand Up @@ -2624,7 +2667,9 @@ class _NvJpeg2kImage(ctypes.Structure):

return d_all_tiles

except Exception:
except Exception as e:
if _warn_or_raise_gpu_fallback("_try_nvjpeg2k_batch_decode", e):
raise
return None


Expand Down Expand Up @@ -2769,7 +2814,9 @@ class _CompInfo(ctypes.Structure):

return result

except Exception:
except Exception as e:
if _warn_or_raise_gpu_fallback("_nvjpeg2k_batch_encode", e):
raise
return None


Expand Down
17 changes: 16 additions & 1 deletion xrspatial/geotiff/_vrt.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,22 @@ def read_vrt(vrt_path: str, *, window=None,
window=(src_r0, src_c0, src_r1, src_c1),
band=src.band - 1, # convert 1-based to 0-based
)
except Exception:
except Exception as e:
# Under XRSPATIAL_GEOTIFF_STRICT=1, surface the read failure
# so partial mosaics are caught in CI. Default mode warns
# once per missing source then continues, preserving the
# historical behaviour. See issue #1662.
import warnings
from . import _geotiff_strict_mode, GeoTIFFFallbackWarning
if _geotiff_strict_mode():
raise
warnings.warn(
f"VRT source {src.filename!r} could not be read "
f"({type(e).__name__}: {e}); skipping. The output "
f"mosaic will have a hole at this tile.",
GeoTIFFFallbackWarning,
stacklevel=2,
)
continue # skip missing/unreadable sources

# Handle source nodata. Cast the sentinel to the *source*
Expand Down
1 change: 1 addition & 0 deletions xrspatial/geotiff/tests/test_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -2642,6 +2642,7 @@ def test_all_lists_supported_functions(self):
# public API. If any of these gets removed or renamed, that is a
# breaking change and should go through a deprecation cycle.
expected = {
'GeoTIFFFallbackWarning',
'open_geotiff',
'read_geotiff_gpu',
'read_geotiff_dask',
Expand Down
Loading
Loading