diff --git a/.claude/sweep-api-consistency-state.csv b/.claude/sweep-api-consistency-state.csv index 3403bb13..3ff54bea 100644 --- a/.claude/sweep-api-consistency-state.csv +++ b/.claude/sweep-api-consistency-state.csv @@ -1,3 +1,3 @@ module,last_inspected,issue,severity_max,categories_found,notes -geotiff,2026-05-11,1605,HIGH,1;5,"Filed open_geotiff(gpu=True) silently dropping window/band (HIGH, #1605) and fix PR -- adds window+band kwargs to read_geotiff_gpu and forwards them through open_geotiff GPU dispatch. Prior issues 1560/1561/1562 from 2026-05-10 audit all CLOSED. MEDIUM: read_geotiff_dask VRT defensive fallback drops window/band/max_pixels at line 1463 (acceptable since open_geotiff routes earlier and direct callers can switch to read_vrt). write_vrt wrapper uses **kwargs instead of explicit signature -- docs list the args but inspect.signature does not; cosmetic." +geotiff,2026-05-11,1631,MEDIUM,3,"Filed write_vrt and write_geotiff_gpu signature/docstring drift vs to_geotiff (MEDIUM, #1631). Fix in PR (TBD): explicit write_vrt(relative, crs_wkt, nodata) signature (was **kwargs); 'cubic' added to write_geotiff_gpu overview_resampling docstring; write_geotiff_gpu(data) typed xr.DataArray|cupy.ndarray to match to_geotiff. Prior 1605/1606/1611/1612/1613/1615/1623 all CLOSED." reproject,2026-05-10,1570,HIGH,2;5,"Filed cross-module attrs['vertical_crs'] type collision (string vs EPSG int) vs xrspatial.geotiff. Fixed in PR (TBD): reproject now writes EPSG int and preserves friendly token under vertical_datum. MEDIUM kwarg-order drift (transform_precision vs chunk_size) and missing type hints vs geotiff documented but not fixed (cosmetic, kwarg-only)." diff --git a/xrspatial/geotiff/__init__.py b/xrspatial/geotiff/__init__.py index a639637b..4ad1f2fd 100644 --- a/xrspatial/geotiff/__init__.py +++ b/xrspatial/geotiff/__init__.py @@ -2663,7 +2663,8 @@ def _read_once(): return result -def write_geotiff_gpu(data, path: str, *, +def write_geotiff_gpu(data: xr.DataArray | cupy.ndarray | np.ndarray, + path: str, *, crs: int | str | None = None, nodata=None, compression: str = 'zstd', @@ -2692,8 +2693,10 @@ def write_geotiff_gpu(data, path: str, *, Parameters ---------- - data : xr.DataArray (CuPy-backed) or cupy.ndarray - 2D raster on GPU. + data : xr.DataArray (CuPy- or NumPy-backed), cupy.ndarray, or np.ndarray + 2D or 3D raster. CuPy-backed inputs stay on device; NumPy/Dask + inputs are uploaded via ``cupy.asarray(np.asarray(data))`` + before compression (matches ``to_geotiff`` parity). path : str Output file path. crs : int, str, or None @@ -2726,7 +2729,10 @@ def write_geotiff_gpu(data, path: str, *, halving until the smallest overview fits in a single tile. overview_resampling : str Resampling method for overviews: 'mean' (default), 'nearest', - 'min', 'max', 'median', or 'mode'. + 'min', 'max', 'median', 'mode', or 'cubic'. ``mode`` and + ``cubic`` fall back to the CPU implementation in + ``xrspatial.geotiff._writer`` so the GPU writer produces the + same overview bytes as the CPU writer. bigtiff : bool or None Force BigTIFF (64-bit offsets). None auto-promotes when the estimated file size would exceed the classic-TIFF 4 GB limit. @@ -3187,7 +3193,10 @@ def _sentinel_for_dtype(nodata_val, dtype): return result -def write_vrt(vrt_path: str, source_files: list[str], **kwargs) -> str: +def write_vrt(vrt_path: str, source_files: list[str], *, + relative: bool = True, + crs_wkt: str | None = None, + nodata: float | None = None) -> str: """Generate a VRT file that mosaics multiple GeoTIFF tiles. Parameters @@ -3208,14 +3217,18 @@ def write_vrt(vrt_path: str, source_files: list[str], **kwargs) -> str: ------- str Path to the written VRT file. - - Notes - ----- - Only the keyword arguments listed above are accepted. Passing any - other keyword raises ``TypeError`` from the underlying writer. """ + # Explicit signature (previously ``**kwargs``) so ``inspect.signature``, + # IDE autocomplete, and ``mypy --strict`` can see the accepted kwargs + # without parsing the docstring. Mirrors ``_vrt.write_vrt`` exactly; if + # that signature changes, this wrapper must be updated in lockstep. from ._vrt import write_vrt as _write_vrt_internal - return _write_vrt_internal(vrt_path, source_files, **kwargs) + return _write_vrt_internal( + vrt_path, source_files, + relative=relative, + crs_wkt=crs_wkt, + nodata=nodata, + ) def plot_geotiff(da: xr.DataArray, **kwargs): diff --git a/xrspatial/geotiff/tests/test_signature_parity_1631.py b/xrspatial/geotiff/tests/test_signature_parity_1631.py new file mode 100644 index 00000000..2f7e0378 --- /dev/null +++ b/xrspatial/geotiff/tests/test_signature_parity_1631.py @@ -0,0 +1,167 @@ +"""Regression test for #1631: public write_vrt / write_geotiff_gpu +signature and docstring parity vs to_geotiff. + +Three drifts were flagged by the api-consistency sweep on 2026-05-11: + +1. ``write_vrt(vrt_path, source_files, **kwargs)`` swallowed every kwarg + into ``**kwargs``. The docstring documented ``relative``, ``crs_wkt``, + ``nodata``, but ``inspect.signature`` and IDE autocomplete saw nothing. +2. ``write_geotiff_gpu``'s ``overview_resampling`` docstring omitted + ``'cubic'``; ``to_geotiff`` lists it and ``make_overview_gpu`` accepts + it (falling back to CPU). +3. ``write_geotiff_gpu(data, ...)`` lacked the type hint that + ``to_geotiff(data, ...)`` has. + +This module pins each of those three guarantees against future drift. +""" +from __future__ import annotations + +import importlib.util +import inspect +import os + +import numpy as np +import pytest +import xarray as xr + +from xrspatial.geotiff import ( + open_geotiff, + to_geotiff, + write_geotiff_gpu, + write_vrt, +) + + +def _gpu_available() -> bool: + """True when cupy imports and CUDA is initialised.""" + if importlib.util.find_spec("cupy") is None: + return False + 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", +) + + +def test_write_vrt_signature_exposes_documented_kwargs(): + """``inspect.signature(write_vrt)`` reports the three accepted kwargs. + + Prior to #1631 the public wrapper used ``**kwargs``, so + ``inspect.signature`` only saw ``vrt_path`` and ``source_files``. + """ + sig = inspect.signature(write_vrt) + params = sig.parameters + assert 'relative' in params + assert 'crs_wkt' in params + assert 'nodata' in params + # Defaults must match _vrt.write_vrt + assert params['relative'].default is True + assert params['crs_wkt'].default is None + assert params['nodata'].default is None + # No more catch-all VAR_KEYWORD + kinds = {p.kind for p in params.values()} + assert inspect.Parameter.VAR_KEYWORD not in kinds + + +def test_write_vrt_unknown_kwarg_rejected_at_public_level(tmp_path): + """A typo'd kwarg now raises ``TypeError`` from the public function + rather than from deep inside ``_vrt.write_vrt``. + """ + arr = np.zeros((8, 8), dtype=np.float32) + da = xr.DataArray( + arr, dims=['y', 'x'], + coords={'y': np.arange(8.0, 0, -1), 'x': np.arange(8.0)}, + attrs={'crs': 4326, 'transform': (1.0, 0, 0.0, 0, -1.0, 8.0)}, + ) + tif_path = str(tmp_path / 't.tif') + to_geotiff(da, tif_path) + + with pytest.raises(TypeError, match='typo_kwarg'): + write_vrt(str(tmp_path / 't.vrt'), [tif_path], typo_kwarg=1) + + +def test_write_vrt_accepts_documented_kwargs(tmp_path): + """Each documented kwarg round-trips through the explicit signature.""" + arr = np.zeros((8, 8), dtype=np.float32) + da = xr.DataArray( + arr, dims=['y', 'x'], + coords={'y': np.arange(8.0, 0, -1), 'x': np.arange(8.0)}, + attrs={'crs': 4326, 'transform': (1.0, 0, 0.0, 0, -1.0, 8.0)}, + ) + tif_path = str(tmp_path / 't.tif') + to_geotiff(da, tif_path) + + vrt_path = str(tmp_path / 't.vrt') + out = write_vrt( + vrt_path, [tif_path], + relative=False, crs_wkt=None, nodata=-9999.0, + ) + assert out == vrt_path + assert os.path.exists(vrt_path) + + +def test_write_geotiff_gpu_docstring_lists_cubic(): + """``overview_resampling`` docstring includes ``'cubic'`` so it + matches ``to_geotiff`` and the underlying ``make_overview_gpu``. + """ + doc = write_geotiff_gpu.__doc__ + assert doc is not None + # Find the overview_resampling block + assert 'overview_resampling' in doc + # The block must mention cubic + block_start = doc.index('overview_resampling') + block_end = doc.index('bigtiff', block_start) + block = doc[block_start:block_end] + assert 'cubic' in block + + +def test_write_geotiff_gpu_data_has_type_hint(): + """``data`` parameter is annotated, matching ``to_geotiff(data, ...)``. + + The annotation also covers ``np.ndarray`` because the implementation + accepts numpy inputs (uploaded via ``cupy.asarray(np.asarray(data))``) + and the test suite exercises that path (e.g. + ``test_backend_kwarg_parity_1561.py`` passes a numpy ``dummy``). + """ + sig = inspect.signature(write_geotiff_gpu) + data_param = sig.parameters['data'] + assert data_param.annotation is not inspect.Parameter.empty + # The annotation is a forward reference under ``from __future__ import + # annotations``; just confirm it mentions the documented types. + ann_str = str(data_param.annotation) + assert 'DataArray' in ann_str + assert 'cupy' in ann_str + assert 'ndarray' in ann_str # numpy parity vs to_geotiff + + +@_gpu_only +def test_write_geotiff_gpu_cubic_overview_round_trip(tmp_path): + """``overview_resampling='cubic'`` works on the GPU writer. + + Sanity check that the docstring update is not advertising an + unsupported codec. ``make_overview_gpu`` falls back to the CPU + cubic implementation for parity with the CPU writer. + """ + import cupy + + arr_cpu = np.random.RandomState(0).rand(256, 256).astype(np.float32) + arr_gpu = cupy.asarray(arr_cpu) + da_gpu = xr.DataArray( + arr_gpu, dims=['y', 'x'], + coords={'y': np.arange(256.0, 0, -1), 'x': np.arange(256.0)}, + ) + path = str(tmp_path / 'cog.tif') + write_geotiff_gpu( + da_gpu, path, + cog=True, tile_size=64, overview_resampling='cubic', + ) + # Overview level 1 = 1/2 resolution + ov = open_geotiff(path, overview_level=1) + assert ov.shape == (128, 128)