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
2 changes: 1 addition & 1 deletion .claude/sweep-api-consistency-state.csv
Original file line number Diff line number Diff line change
@@ -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)."
35 changes: 24 additions & 11 deletions xrspatial/geotiff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down
167 changes: 167 additions & 0 deletions xrspatial/geotiff/tests/test_signature_parity_1631.py
Original file line number Diff line number Diff line change
@@ -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)
Loading