diff --git a/.claude/sweep-api-consistency-state.csv b/.claude/sweep-api-consistency-state.csv index 75e332a3..fbac2b12 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-12,1654,MEDIUM,3,"Filed type-annotation drift #1654 (MEDIUM Cat 3): public window/path/on_gpu_failure annotations missing on some siblings while others carry them. Fixed on deep-sweep-api-consistency-geotiff-2026-05-12 with PR (TBD): added tuple|None on open_geotiff/read_vrt window, str|BinaryIO on to_geotiff/write_geotiff_gpu path, str on open_geotiff/read_geotiff_gpu on_gpu_failure plus the deprecated gpu alias. Regression test test_signature_annotations_1654.py pins each annotation. Prior drifts surveyed: chunks default 512 vs None (intentional, read_geotiff_dask is dask-only); crs vs crs_wkt on write_vrt (VRT is WKT-only); write_vrt returns str (intentional backward-compat); nodata float|None vs unannotated (LOW, prior sweep). cuda-validated." +geotiff,2026-05-12,1683;1684;1685,MEDIUM,3;5,"Sweep v2 (deep-sweep-api-consistency-geotiff-2026-05-12-v2). 3 MEDIUM findings filed and fixed: #1683 bigtiff docstring gap on to_geotiff (Cat 3, PR #1686); #1684 write_vrt nodata float|None widened to float|int|None (Cat 3, PR #1687); #1685 open_geotiff silent overview_level/on_gpu_failure drop on VRT sources (Cat 5, PR #1689). Prior v1 fix (#1654) covered type-annotation parity across window/path/on_gpu_failure. cuda-validated." 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 fffd1869..0ff104f4 100644 --- a/xrspatial/geotiff/__init__.py +++ b/xrspatial/geotiff/__init__.py @@ -3421,7 +3421,7 @@ def _sentinel_for_dtype(nodata_val, dtype): def write_vrt(vrt_path: str, source_files: list[str], *, relative: bool = True, crs_wkt: str | None = None, - nodata: float | None = None) -> str: + nodata: float | int | None = None) -> str: """Generate a VRT file that mosaics multiple GeoTIFF tiles. Parameters @@ -3435,8 +3435,11 @@ def write_vrt(vrt_path: str, source_files: list[str], *, crs_wkt : str or None, optional CRS as a WKT string. If None, the CRS is taken from the first source GeoTIFF. - nodata : float or None, optional + nodata : float, int, or None, optional NoData value. If None, taken from the first source GeoTIFF. + Integer sentinels (e.g. ``65535`` for uint16, ``-9999`` for + int32) are accepted so the surface lines up with the + ``nodata`` kwarg on ``to_geotiff`` and ``write_geotiff_gpu``. Returns ------- diff --git a/xrspatial/geotiff/_vrt.py b/xrspatial/geotiff/_vrt.py index a9d35458..5319d4a8 100644 --- a/xrspatial/geotiff/_vrt.py +++ b/xrspatial/geotiff/_vrt.py @@ -595,7 +595,7 @@ def read_vrt(vrt_path: str, *, window=None, def write_vrt(vrt_path: str, source_files: list[str], *, relative: bool = True, crs_wkt: str | None = None, - nodata: float | None = None) -> str: + nodata: float | int | None = None) -> str: """Generate a VRT file that mosaics multiple GeoTIFF tiles. Each source file is placed in the virtual raster based on its @@ -611,8 +611,11 @@ def write_vrt(vrt_path: str, source_files: list[str], *, Store source paths relative to the VRT file. crs_wkt : str or None CRS as WKT string. If None, taken from the first source. - nodata : float or None - NoData value. If None, taken from the first source. + nodata : float, int, or None + NoData value. If None, taken from the first source. Integer + sentinels (e.g. ``65535`` for uint16, ``-9999`` for int32) are + accepted so the surface lines up with the ``nodata`` kwarg on + ``to_geotiff`` and ``write_geotiff_gpu``. Returns ------- diff --git a/xrspatial/geotiff/tests/test_write_vrt_int_nodata_1684.py b/xrspatial/geotiff/tests/test_write_vrt_int_nodata_1684.py new file mode 100644 index 00000000..f9bf5132 --- /dev/null +++ b/xrspatial/geotiff/tests/test_write_vrt_int_nodata_1684.py @@ -0,0 +1,88 @@ +"""Regression test for #1684: ``write_vrt`` nodata annotation rejected ints. + +The api-consistency sweep on 2026-05-12 flagged that +``xrspatial.geotiff.write_vrt`` annotated ``nodata`` as ``float | None`` +even though the sibling writers ``to_geotiff`` and ``write_geotiff_gpu`` +accept ``float``, ``int``, or ``None``. Integer sentinels (``65535`` for +uint16, ``-9999`` for int32) flow through the rest of the I/O surface +unchanged, so the float-only hint forced callers either to cast (losing +the exact sentinel) or to ignore the static-type complaint. + +This module pins the widened annotation and confirms an integer nodata +round-trips through ``write_vrt`` -> ``read_vrt`` losslessly. +""" +from __future__ import annotations + +import inspect +import typing + +import numpy as np +import xarray as xr + +from xrspatial.geotiff import to_geotiff, write_vrt +from xrspatial.geotiff import _vrt as _vrt_module + + +def _nodata_annotation(fn): + sig = inspect.signature(fn) + return sig.parameters["nodata"].annotation + + +def test_write_vrt_public_nodata_accepts_int_annotation(): + """The public wrapper widens the annotation to include int.""" + ann = _nodata_annotation(write_vrt) + # Allow either typing.Union[float, int, None] or float | int | None. + if isinstance(ann, str): + # Forward-referenced string annotation (rare here; defensive). + assert "int" in ann, ann + return + if hasattr(typing, "get_args"): + args = set(typing.get_args(ann)) + if args: + assert int in args, args + return + # Fallback: stringify the annotation. + assert "int" in str(ann), str(ann) + + +def test_write_vrt_internal_nodata_accepts_int_annotation(): + """The internal helper in `_vrt.py` mirrors the public surface.""" + ann = _nodata_annotation(_vrt_module.write_vrt) + if isinstance(ann, str): + assert "int" in ann, ann + return + if hasattr(typing, "get_args"): + args = set(typing.get_args(ann)) + if args: + assert int in args, args + return + assert "int" in str(ann), str(ann) + + +def test_write_vrt_int_nodata_round_trips(tmp_path): + """An int nodata renders to ```` and parses back the same.""" + # Build a tiny uint16 tile so the sentinel makes sense. + arr = np.array([[100, 200, 65535], + [300, 400, 500]], dtype=np.uint16) + da = xr.DataArray( + arr, + dims=["y", "x"], + coords={ + "y": np.array([0.5, 1.5]), + "x": np.array([0.5, 1.5, 2.5]), + }, + attrs={"crs": 4326}, + ) + tif_path = tmp_path / "source.tif" + to_geotiff(da, str(tif_path)) + + vrt_path = tmp_path / "mosaic.vrt" + # Passing an int sentinel must not raise; the surface should match + # to_geotiff's "float, int, or None" contract. + write_vrt(str(vrt_path), [str(tif_path)], nodata=65535) + + # Confirm the int round-trips through the parser back into a VRT band. + parsed = _vrt_module.parse_vrt( + vrt_path.read_text(), vrt_dir=str(tmp_path)) + band_nodata = parsed.bands[0].nodata + assert band_nodata == 65535, band_nodata