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-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)."
7 changes: 5 additions & 2 deletions xrspatial/geotiff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
-------
Expand Down
9 changes: 6 additions & 3 deletions xrspatial/geotiff/_vrt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
-------
Expand Down
88 changes: 88 additions & 0 deletions xrspatial/geotiff/tests/test_write_vrt_int_nodata_1684.py
Original file line number Diff line number Diff line change
@@ -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 ``<NoDataValue>`` 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
Loading