Skip to content

Commit bfd914b

Browse files
authored
Pin nodata + streaming_buffer_bytes annotations on writer trio (#1705) (#1707)
Follow-up to #1654. The api-consistency sweep on 2026-05-12 found two remaining annotation gaps across xrspatial.geotiff's writer trio. * nodata: write_vrt got `float | int | None` in #1684, but to_geotiff and write_geotiff_gpu still exposed bare `nodata=None`. Type-checkers inferred Any on the eager writers while inferring float|int|None on the VRT writer, even though all three docstrings describe the same accepted-type set. * streaming_buffer_bytes: `int = 256*1024*1024` on to_geotiff vs `int | None = None` on write_geotiff_gpu. The GPU writer no-ops the kwarg (`del streaming_buffer_bytes`) so the type signature was the only consistency dimension. Pin both to `int = 256*1024*1024` so a caller forwarding the same kwargs to either entry point sees the same hint and default. Annotation-only change; no runtime behaviour, defaults (effective for to_geotiff and the GPU no-op), or kwarg renames. The existing write_geotiff_gpu del-on-entry remains, so the value continues to be ignored on the GPU path. test_signature_annotations_1705.py pins each annotation plus a runtime smoke test for to_geotiff(nodata=int) and a no-op assertion for write_geotiff_gpu(streaming_buffer_bytes=...) so the GPU writer's output stays byte-identical regardless of the kwarg value. Closes #1705.
1 parent 0699aae commit bfd914b

2 files changed

Lines changed: 149 additions & 5 deletions

File tree

xrspatial/geotiff/__init__.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,7 +1000,7 @@ def _extract_rich_tags(attrs: dict) -> dict:
10001000
def to_geotiff(data: xr.DataArray | np.ndarray,
10011001
path: str | BinaryIO, *,
10021002
crs: int | str | None = None,
1003-
nodata=None,
1003+
nodata: float | int | None = None,
10041004
compression: str = 'zstd',
10051005
compression_level: int | None = None,
10061006
tiled: bool = True,
@@ -2854,7 +2854,7 @@ def _read_once():
28542854
def write_geotiff_gpu(data: xr.DataArray | cupy.ndarray | np.ndarray,
28552855
path: str | BinaryIO, *,
28562856
crs: int | str | None = None,
2857-
nodata=None,
2857+
nodata: float | int | None = None,
28582858
compression: str = 'zstd',
28592859
compression_level: int | None = None,
28602860
tiled: bool = True,
@@ -2865,7 +2865,7 @@ def write_geotiff_gpu(data: xr.DataArray | cupy.ndarray | np.ndarray,
28652865
overview_resampling: str = 'mean',
28662866
bigtiff: bool | None = None,
28672867
max_z_error: float = 0.0,
2868-
streaming_buffer_bytes: int | None = None) -> None:
2868+
streaming_buffer_bytes: int = 256 * 1024 * 1024) -> None:
28692869
"""Write a CuPy-backed DataArray as a GeoTIFF with GPU compression.
28702870
28712871
Tiles are extracted and compressed on the GPU via nvCOMP, then
@@ -2960,10 +2960,12 @@ def write_geotiff_gpu(data: xr.DataArray | cupy.ndarray | np.ndarray,
29602960
does not implement LERC (nvCOMP has no LERC backend), so any
29612961
non-zero value raises ``ValueError``. Accepted at the signature
29622962
level for API parity with ``to_geotiff``.
2963-
streaming_buffer_bytes : int or None
2963+
streaming_buffer_bytes : int
29642964
Accepted for API parity with ``to_geotiff``. The GPU writer
29652965
materialises the entire array on device and has no streaming
2966-
concept, so this kwarg is a no-op.
2966+
concept, so this kwarg is a no-op. Default matches
2967+
``to_geotiff`` (256 MB) so callers passing the same kwargs to
2968+
either entry point see the same default and the same type.
29672969
"""
29682970
if not tiled:
29692971
raise ValueError(
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Regression test for #1705: writer-trio nodata / streaming_buffer_bytes annotations.
2+
3+
Follow-up to #1654. The api-consistency sweep on 2026-05-12 found two
4+
remaining annotation gaps across the public writer trio (``to_geotiff``,
5+
``write_geotiff_gpu``, ``write_vrt``):
6+
7+
* ``nodata`` -- annotated as ``float | int | None`` on ``write_vrt``
8+
(added by #1684) but bare ``=None`` on ``to_geotiff`` and
9+
``write_geotiff_gpu``. The three docstrings all describe the same
10+
accepted-type set ("float, int, or None"), so the annotation should
11+
match across siblings.
12+
13+
* ``streaming_buffer_bytes`` -- ``int`` (default 256 MB) on
14+
``to_geotiff`` versus ``int | None`` (default None) on
15+
``write_geotiff_gpu``. The GPU writer no-ops this kwarg
16+
(``del streaming_buffer_bytes`` in the body) so the type signature
17+
was the only consistency dimension; pin both to ``int`` so callers
18+
passing the same kwargs to either entry point see the same hint.
19+
20+
This module pins both annotations against future drift.
21+
"""
22+
from __future__ import annotations
23+
24+
import inspect
25+
26+
from xrspatial.geotiff import (
27+
to_geotiff,
28+
write_geotiff_gpu,
29+
write_vrt,
30+
)
31+
32+
33+
def _annotation(fn, param_name):
34+
"""Return the stringified annotation for ``fn``'s ``param_name``."""
35+
sig = inspect.signature(fn)
36+
p = sig.parameters[param_name]
37+
assert p.annotation is not inspect.Parameter.empty, (
38+
f"{fn.__name__}({param_name}=...) is missing a type annotation"
39+
)
40+
return str(p.annotation)
41+
42+
43+
# --- nodata: float | int | None on every writer entry point ---
44+
45+
46+
def test_to_geotiff_nodata_annotated():
47+
assert _annotation(to_geotiff, 'nodata') == 'float | int | None'
48+
49+
50+
def test_write_geotiff_gpu_nodata_annotated():
51+
assert _annotation(write_geotiff_gpu, 'nodata') == 'float | int | None'
52+
53+
54+
def test_write_vrt_nodata_annotated():
55+
"""Pre-existing annotation from #1684 -- keep it pinned."""
56+
assert _annotation(write_vrt, 'nodata') == 'float | int | None'
57+
58+
59+
# --- streaming_buffer_bytes: int on both writer entry points ---
60+
61+
62+
def test_to_geotiff_streaming_buffer_bytes_annotated():
63+
"""Pre-existing -- ``int`` with a 256 MB default."""
64+
assert _annotation(to_geotiff, 'streaming_buffer_bytes') == 'int'
65+
assert (
66+
inspect.signature(to_geotiff)
67+
.parameters['streaming_buffer_bytes']
68+
.default
69+
== 256 * 1024 * 1024
70+
)
71+
72+
73+
def test_write_geotiff_gpu_streaming_buffer_bytes_annotated():
74+
"""GPU writer must agree with ``to_geotiff`` on type and default so a
75+
caller forwarding the same kwargs to either entry point sees the same
76+
hint. The kwarg is a runtime no-op on the GPU writer (deleted on
77+
entry); the annotation parity is the only consistency dimension."""
78+
assert _annotation(
79+
write_geotiff_gpu, 'streaming_buffer_bytes'
80+
) == 'int'
81+
assert (
82+
inspect.signature(write_geotiff_gpu)
83+
.parameters['streaming_buffer_bytes']
84+
.default
85+
== 256 * 1024 * 1024
86+
)
87+
88+
89+
# --- Smoke: the new annotations do not break runtime call semantics ---
90+
91+
92+
def test_to_geotiff_nodata_int_runtime(tmp_path):
93+
"""``nodata=<int>`` still round-trips through ``to_geotiff`` and the
94+
sentinel survives into the read-back attrs."""
95+
import numpy as np
96+
import xarray as xr
97+
98+
from xrspatial.geotiff import open_geotiff
99+
100+
arr = np.full((8, 8), -9999, dtype=np.int32)
101+
arr[2:6, 2:6] = 42
102+
da = xr.DataArray(
103+
arr, dims=['y', 'x'],
104+
coords={'y': np.arange(8.0, 0, -1), 'x': np.arange(8.0)},
105+
attrs={'crs': 4326, 'transform': (1.0, 0, 0.0, 0, -1.0, 8.0)},
106+
)
107+
path = str(tmp_path / 'nodata_int.tif')
108+
to_geotiff(da, path, nodata=-9999)
109+
r = open_geotiff(path)
110+
assert r.attrs.get('nodata') == -9999
111+
112+
113+
def test_write_geotiff_gpu_streaming_buffer_bytes_runtime_noop(tmp_path):
114+
"""Passing an explicit ``streaming_buffer_bytes`` to the GPU writer
115+
must remain a no-op. The body still does ``del streaming_buffer_bytes``
116+
so the value has no effect on the produced file."""
117+
import importlib.util
118+
119+
if importlib.util.find_spec("cupy") is None:
120+
import pytest
121+
122+
pytest.skip("cupy required for write_geotiff_gpu")
123+
124+
import cupy
125+
import numpy as np
126+
import xarray as xr
127+
128+
arr_cpu = np.arange(64 * 64, dtype=np.float32).reshape(64, 64)
129+
arr_gpu = cupy.asarray(arr_cpu)
130+
da_gpu = xr.DataArray(
131+
arr_gpu, dims=['y', 'x'],
132+
coords={'y': np.arange(64.0, 0, -1), 'x': np.arange(64.0)},
133+
attrs={'crs': 4326, 'transform': (1.0, 0, 0.0, 0, -1.0, 64.0)},
134+
)
135+
p1 = str(tmp_path / 'default.tif')
136+
p2 = str(tmp_path / 'override.tif')
137+
write_geotiff_gpu(da_gpu, p1)
138+
write_geotiff_gpu(da_gpu, p2, streaming_buffer_bytes=8 * 1024 * 1024)
139+
# Both files have identical sizes -- the buffer kwarg is a no-op.
140+
import os
141+
142+
assert os.path.getsize(p1) == os.path.getsize(p2)

0 commit comments

Comments
 (0)