Skip to content

Commit 1a8dece

Browse files
committed
Block write_geotiff_gpu(file_like, cog=True) for parity with to_geotiff (#1652)
to_geotiff has long rejected cog=True + file-like destinations, but the explicit write_geotiff_gpu entry point silently accepted the combo and emitted a COG into the buffer. The two writers should agree on which inputs they refuse: to_geotiff(gpu=True, cog=True, path=BytesIO) raises, so write_geotiff_gpu(da, BytesIO, cog=True) should too. Mirror the existing to_geotiff guard on the GPU entry point. Non-cog file-like writes remain supported on this path (the gate is targeted at cog=True only). Add regression coverage in test_bytesio_source.py. Also clarify the path/compression docstring on write_geotiff_gpu: - path: document that file-like destinations are accepted (cog=True requires a string path). - compression: list the full codec set the function actually accepts and note the deliberate JPEG asymmetry with to_geotiff (#1651 downgraded to docs-only after PR #1647 confirmed advanced-API intent). Update .claude/sweep-api-consistency-state.csv with the 2026-05-11 re-audit row.
1 parent babb72e commit 1a8dece

3 files changed

Lines changed: 76 additions & 6 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module,last_inspected,issue,severity_max,categories_found,notes
2-
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."
2+
geotiff,2026-05-11,1652,MEDIUM,5,"Filed MEDIUM file-like cog=True drift #1652 (write_geotiff_gpu accepted BytesIO+cog=True; to_geotiff blocked it). Fixed in PR (TBD): mirror to_geotiff's gate on the explicit GPU writer; add regression tests in test_bytesio_source.py. Also filed #1651 (JPEG acceptance drift) but downgraded to LOW after #1647 confirmed write_geotiff_gpu(jpeg) is deliberate advanced-API; PR (TBD) carries the docstring clarification. Prior 1631/1644 noted in earlier rows (1644 open, fix in PR #1649). LOW: streaming_buffer_bytes default drift to_geotiff=256MB vs write_geotiff_gpu=None (no functional impact, explicit forwarding); to_geotiff data: annotation misses cupy.ndarray (accepted via auto-dispatch). cuda-validated."
33
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)."

xrspatial/geotiff/__init__.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2714,7 +2714,7 @@ def _read_once():
27142714

27152715

27162716
def write_geotiff_gpu(data: xr.DataArray | cupy.ndarray | np.ndarray,
2717-
path: str, *,
2717+
path, *,
27182718
crs: int | str | None = None,
27192719
nodata=None,
27202720
compression: str = 'zstd',
@@ -2747,15 +2747,27 @@ def write_geotiff_gpu(data: xr.DataArray | cupy.ndarray | np.ndarray,
27472747
2D or 3D raster. CuPy-backed inputs stay on device; NumPy/Dask
27482748
inputs are uploaded via ``cupy.asarray(np.asarray(data))``
27492749
before compression (matches ``to_geotiff`` parity).
2750-
path : str
2751-
Output file path.
2750+
path : str or binary file-like
2751+
Output file path or any object with a ``write`` method
2752+
(e.g. ``io.BytesIO``). ``cog=True`` requires a string path:
2753+
the auto-dispatch path through ``to_geotiff(gpu=True, cog=True)``
2754+
rejects file-like destinations, and the explicit GPU writer
2755+
mirrors that rule (issue #1652).
27522756
crs : int, str, or None
27532757
EPSG code or WKT string.
27542758
nodata : float, int, or None
27552759
NoData value.
27562760
compression : str
2757-
'zstd' (default, fastest on GPU), 'deflate', 'jpeg', or 'none'.
2758-
JPEG uses nvJPEG when available, falling back to Pillow.
2761+
Codec name. Accepts the full set ``to_geotiff`` does:
2762+
``'none'``, ``'deflate'``, ``'lzw'``, ``'jpeg'``, ``'packbits'``,
2763+
``'zstd'`` (default, fastest on GPU), ``'lz4'``, ``'jpeg2000'``
2764+
(alias ``'j2k'``), or ``'lerc'``. ``'jpeg'`` uses nvJPEG when
2765+
available, falling back to Pillow. Note that ``to_geotiff``
2766+
rejects ``compression='jpeg'`` up front because the encoder
2767+
omits the JPEGTables tag (347); the explicit GPU writer
2768+
accepts it on the understanding that callers know the
2769+
cross-reader caveat. Codecs unsupported by nvCOMP fall back
2770+
to the CPU encoder transparently.
27592771
compression_level : int or None
27602772
Compression effort level. Accepted for API compatibility but
27612773
currently ignored -- nvCOMP does not expose level control.
@@ -2810,6 +2822,17 @@ def write_geotiff_gpu(data: xr.DataArray | cupy.ndarray | np.ndarray,
28102822
"max_z_error is not supported on the GPU writer "
28112823
"(nvCOMP has no LERC backend). Use to_geotiff(..., gpu=False) "
28122824
"or omit max_z_error.")
2825+
# Mirror to_geotiff's file-like + cog=True rejection. The auto-dispatch
2826+
# path through ``to_geotiff(gpu=True, cog=True, path=BytesIO)`` raises
2827+
# before reaching here; the explicit GPU writer mirrors the gate so
2828+
# callers cannot bypass it (issue #1652). Non-cog file-like writes
2829+
# remain supported on this entry point.
2830+
_path_is_file_like = (
2831+
not isinstance(path, str)) and hasattr(path, 'write')
2832+
if _path_is_file_like and cog:
2833+
raise ValueError(
2834+
"cog=True is not supported for file-like destinations on the "
2835+
"GPU writer. Pass a string path or set cog=False.")
28132836
# streaming_buffer_bytes is intentionally a no-op on the GPU path;
28142837
# the kwarg exists for API parity with to_geotiff so callers can pass
28152838
# the same kwargs to both entry points without filtering.

xrspatial/geotiff/tests/test_bytesio_source.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,50 @@ def seek(self, *a, **k):
268268

269269
assert _is_file_like(io.BytesIO(b'x')) is True
270270
assert _is_file_like(ReadSeekNoTell()) is False
271+
272+
273+
class TestWriteGeotiffGpuBytesIO:
274+
"""Regression coverage for ``write_geotiff_gpu`` file-like behaviour.
275+
276+
``to_geotiff(gpu=True, ...)`` always rejects BytesIO destinations paired
277+
with ``cog=True`` (the auto-dispatch path's existing guard). The explicit
278+
GPU writer used to silently accept that combo and produce a COG into the
279+
buffer, so the two entry points disagreed on what ``to_geotiff(gpu=True,
280+
cog=True, path=BytesIO)`` does. These tests pin the mirrored gate added
281+
by issue #1652 and confirm the non-cog file-like path still works.
282+
"""
283+
284+
def test_cog_with_bytesio_rejected_1652(self):
285+
cupy = pytest.importorskip("cupy")
286+
da = xr.DataArray(
287+
cupy.asarray(np.random.rand(64, 64).astype(np.float32)),
288+
dims=['y', 'x'],
289+
coords={'y': np.arange(64.0), 'x': np.arange(64.0)},
290+
attrs={'crs': 4326},
291+
)
292+
from xrspatial.geotiff import write_geotiff_gpu
293+
294+
buf = io.BytesIO()
295+
with pytest.raises(ValueError, match='cog=True'):
296+
write_geotiff_gpu(da, buf, cog=True)
297+
298+
def test_non_cog_bytesio_still_works_1652(self):
299+
cupy = pytest.importorskip("cupy")
300+
arr_cpu = np.random.rand(64, 64).astype(np.float32)
301+
da = xr.DataArray(
302+
cupy.asarray(arr_cpu),
303+
dims=['y', 'x'],
304+
coords={'y': np.arange(64.0), 'x': np.arange(64.0)},
305+
attrs={'crs': 4326},
306+
)
307+
from xrspatial.geotiff import write_geotiff_gpu
308+
309+
buf = io.BytesIO()
310+
# Non-cog file-like write is still supported on the explicit GPU
311+
# writer; only cog=True is gated.
312+
write_geotiff_gpu(da, buf)
313+
assert len(buf.getvalue()) > 0
314+
315+
# Verify it round-trips through open_geotiff
316+
rd = open_geotiff(io.BytesIO(buf.getvalue()))
317+
np.testing.assert_allclose(np.asarray(rd.values), arr_cpu)

0 commit comments

Comments
 (0)