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
1 change: 1 addition & 0 deletions .claude/sweep-api-consistency-state.csv
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
module,last_inspected,issue,severity_max,categories_found,notes
geotiff,2026-05-11,1644,MEDIUM,3,"Filed write_geotiff_gpu compression docstring drift vs to_geotiff (MEDIUM Cat 3, #1644). Fix on deep-sweep-api-consistency-geotiff-2026-05-11-1778545740: sync the full 9-codec list into the docstring and note GPU vs CPU encode paths; regression test test_compression_docstring_1644.py pins the codec list and exercises each CPU-fallback codec end-to-end. Other potential drifts surveyed: write_vrt returns str while to_geotiff/write_geotiff_gpu return None (LOW, intentional backward-compat); write_vrt nodata typed float|None vs int-accepting siblings (LOW, PEP 484 int->float compat); kwarg-only ordering drift across read functions (LOW, no user impact). Prior issues 1631/1637/1615/1560/1541/1562 all CLOSED."
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."
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)."
27 changes: 24 additions & 3 deletions xrspatial/geotiff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2714,7 +2714,7 @@ def _read_once():


def write_geotiff_gpu(data: xr.DataArray | cupy.ndarray | np.ndarray,
path: str, *,
path, *,
crs: int | str | None = None,
nodata=None,
compression: str = 'zstd',
Expand Down Expand Up @@ -2747,8 +2747,12 @@ def write_geotiff_gpu(data: xr.DataArray | cupy.ndarray | 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.
path : str or binary file-like
Output file path or any object with a ``write`` method
(e.g. ``io.BytesIO``). ``cog=True`` requires a string path:
the auto-dispatch path through ``to_geotiff(gpu=True, cog=True)``
rejects file-like destinations, and the explicit GPU writer
mirrors that rule (issue #1652).
crs : int, str, or None
EPSG code or WKT string.
nodata : float, int, or None
Expand Down Expand Up @@ -2837,6 +2841,23 @@ def write_geotiff_gpu(data: xr.DataArray | cupy.ndarray | np.ndarray,
"max_z_error is not supported on the GPU writer "
"(nvCOMP has no LERC backend). Use to_geotiff(..., gpu=False) "
"or omit max_z_error.")
# Mirror to_geotiff's path-type + cog=True gating verbatim so callers
# see identical errors from the two entry points. The auto-dispatch
# path through ``to_geotiff(gpu=True, cog=True, path=BytesIO)`` raises
# before reaching here; the explicit GPU writer mirrors the same gate
# so callers cannot bypass it (issue #1652). Non-cog file-like writes
# remain supported on this entry point.
_path_is_file_like = (
not isinstance(path, str)) and hasattr(path, 'write')
if _path_is_file_like:
if cog:
raise ValueError(
"cog=True is not supported for file-like destinations. "
"Pass a string path or write to BytesIO without cog=True.")
elif not isinstance(path, str):
raise TypeError(
f"path must be a str or a binary file-like with a write() "
f"method, got {type(path).__name__}")
# streaming_buffer_bytes is intentionally a no-op on the GPU path;
# the kwarg exists for API parity with to_geotiff so callers can pass
# the same kwargs to both entry points without filtering.
Expand Down
117 changes: 117 additions & 0 deletions xrspatial/geotiff/tests/test_bytesio_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""
from __future__ import annotations

import importlib.util
import io
from concurrent.futures import ThreadPoolExecutor

Expand All @@ -17,6 +18,26 @@
from xrspatial.geotiff._reader import _BytesIOSource, read_to_array


def _gpu_available() -> bool:
"""True when cupy imports AND a CUDA runtime is initialised.

Mirrors the helper used in other geotiff GPU tests so the BytesIO
GPU-writer tests skip cleanly on hosts where CuPy is installed but
CUDA is unavailable (Copilot review on #1653).
"""
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 _make_da(height=32, width=40, dtype=np.float32):
arr = np.arange(height * width, dtype=dtype).reshape(height, width)
# Simple geotransform with negative pixel_height (north-up)
Expand Down Expand Up @@ -268,3 +289,99 @@ def seek(self, *a, **k):

assert _is_file_like(io.BytesIO(b'x')) is True
assert _is_file_like(ReadSeekNoTell()) is False


class TestWriteGeotiffGpuBytesIO:
"""Regression coverage for ``write_geotiff_gpu`` file-like behaviour.

``to_geotiff(gpu=True, ...)`` always rejects BytesIO destinations paired
with ``cog=True`` (the auto-dispatch path's existing guard). The explicit
GPU writer used to silently accept that combo and produce a COG into the
buffer, so the two entry points disagreed on what ``to_geotiff(gpu=True,
cog=True, path=BytesIO)`` does. These tests pin the mirrored gate added
by issue #1652 and confirm the non-cog file-like path still works.
"""

@_gpu_only
def test_cog_with_bytesio_rejected_1652(self):
import cupy
da = xr.DataArray(
cupy.asarray(np.random.rand(64, 64).astype(np.float32)),
dims=['y', 'x'],
coords={'y': np.arange(64.0), 'x': np.arange(64.0)},
attrs={'crs': 4326},
)
from xrspatial.geotiff import write_geotiff_gpu

buf = io.BytesIO()
with pytest.raises(ValueError, match='cog=True'):
write_geotiff_gpu(da, buf, cog=True)

@_gpu_only
def test_cog_with_bytesio_error_matches_to_geotiff_1652(self):
"""The error string must match ``to_geotiff``'s gate verbatim so
downstream callers can rely on a single message (Copilot review
on #1653)."""
import cupy
da = xr.DataArray(
cupy.asarray(np.random.rand(64, 64).astype(np.float32)),
dims=['y', 'x'],
coords={'y': np.arange(64.0), 'x': np.arange(64.0)},
attrs={'crs': 4326},
)
from xrspatial.geotiff import write_geotiff_gpu

# to_geotiff's canonical message; mirrored verbatim in
# write_geotiff_gpu's gate.
expected = (
"cog=True is not supported for file-like destinations. "
"Pass a string path or write to BytesIO without cog=True."
)

buf = io.BytesIO()
with pytest.raises(ValueError) as exc_info:
write_geotiff_gpu(da, buf, cog=True)
assert str(exc_info.value) == expected

# And the CPU writer raises the same string for parity.
with pytest.raises(ValueError) as exc_info_cpu:
to_geotiff(_make_da(), io.BytesIO(), cog=True)
assert str(exc_info_cpu.value) == expected

@_gpu_only
def test_invalid_path_type_raises_typeerror_1652(self):
"""Mirror to_geotiff's TypeError for non-str, non-file-like paths
so callers see identical behaviour from both entry points."""
import cupy
da = xr.DataArray(
cupy.asarray(np.random.rand(64, 64).astype(np.float32)),
dims=['y', 'x'],
coords={'y': np.arange(64.0), 'x': np.arange(64.0)},
attrs={'crs': 4326},
)
from xrspatial.geotiff import write_geotiff_gpu

with pytest.raises(TypeError, match="path must be a str"):
write_geotiff_gpu(da, 42) # int is neither str nor file-like

@_gpu_only
def test_non_cog_bytesio_still_works_1652(self):
import cupy
arr_cpu = np.random.rand(64, 64).astype(np.float32)
da = xr.DataArray(
cupy.asarray(arr_cpu),
dims=['y', 'x'],
Comment on lines +306 to +373
coords={'y': np.arange(64.0), 'x': np.arange(64.0)},
attrs={'crs': 4326},
)
from xrspatial.geotiff import write_geotiff_gpu

buf = io.BytesIO()
# Non-cog file-like write is still supported on the explicit GPU
# writer; only cog=True is gated.
write_geotiff_gpu(da, buf)
assert len(buf.getvalue()) > 0

# Verify it round-trips through open_geotiff
rd = open_geotiff(io.BytesIO(buf.getvalue()))
np.testing.assert_allclose(np.asarray(rd.values), arr_cpu)
Loading