Skip to content

Commit 5f75ca9

Browse files
committed
geotiff: writers return the written path (#1938)
to_geotiff and write_geotiff_gpu returned None while write_vrt returned str. The drift broke mypy consumers who handled the three writers uniformly and made the Sphinx-rendered docs surface inconsistent. Make to_geotiff and write_geotiff_gpu return the path argument (a str for filesystem paths, the file-like object for BytesIO destinations). The change is additive: existing callers that discarded the previous None return are unaffected, and chained writes (path = to_geotiff(da, out); da2 = open_geotiff(path)) work without round-tripping through a variable. write_vrt's existing str return is preserved. Regression test pins the runtime return value for to_geotiff and write_vrt across str + BytesIO + COG + dask-streaming destinations, plus a GPU branch gated on cupy+CUDA. A signature-level assertion pins the return annotations of all three writers so a future change cannot silently revert the contract.
1 parent eb66cf4 commit 5f75ca9

3 files changed

Lines changed: 208 additions & 5 deletions

File tree

xrspatial/geotiff/_writers/eager.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def to_geotiff(data: xr.DataArray | np.ndarray,
6464
streaming_buffer_bytes: int = 256 * 1024 * 1024,
6565
max_z_error: float = 0.0,
6666
photometric: str | int = 'auto',
67-
allow_internal_only_jpeg: bool = False) -> None:
67+
allow_internal_only_jpeg: bool = False) -> str | BinaryIO:
6868
"""Write data as a GeoTIFF or Cloud Optimized GeoTIFF.
6969
7070
Dask-backed DataArrays are written in streaming mode: one tile-row
@@ -204,6 +204,16 @@ def to_geotiff(data: xr.DataArray | np.ndarray,
204204
GPU dispatch path so callers can reach the same experimental
205205
encode via ``to_geotiff(..., gpu=True)``. See issue #1845.
206206
207+
Returns
208+
-------
209+
str or binary file-like
210+
The ``path`` argument (a string for filesystem paths, the
211+
file-like object for BytesIO destinations). Returning the path
212+
lines up with ``write_vrt`` and lets callers chain a write into
213+
a read without round-tripping through a variable; existing
214+
callers that discarded the previous ``None`` return are
215+
unaffected. See issue #1938.
216+
207217
Raises
208218
------
209219
ValueError
@@ -376,7 +386,7 @@ def to_geotiff(data: xr.DataArray | np.ndarray,
376386
bigtiff=bigtiff,
377387
max_z_error=max_z_error,
378388
photometric=photometric)
379-
return
389+
return path
380390

381391
# Dispatch to write_geotiff_gpu when GPU was selected (explicit
382392
# ``gpu=True`` or auto-detected CuPy data). ``auto_detected_gpu``
@@ -421,7 +431,7 @@ def to_geotiff(data: xr.DataArray | np.ndarray,
421431
photometric=photometric,
422432
allow_internal_only_jpeg=allow_internal_only_jpeg,
423433
)
424-
return
434+
return path
425435
except ImportError as e:
426436
# ``write_geotiff_gpu`` raises ImportError when cupy itself
427437
# can't be imported. nvCOMP absence doesn't surface here:
@@ -572,7 +582,7 @@ def to_geotiff(data: xr.DataArray | np.ndarray,
572582
max_z_error=max_z_error,
573583
photometric=photometric,
574584
)
575-
return
585+
return path
576586

577587
# Eager compute (numpy, CuPy, or dask+COG)
578588
if hasattr(raw, 'get'):
@@ -654,6 +664,7 @@ def to_geotiff(data: xr.DataArray | np.ndarray,
654664
max_z_error=max_z_error,
655665
photometric=photometric,
656666
)
667+
return path
657668

658669

659670
def _write_single_tile(chunk_data, path, geo_transform, epsg, wkt,

xrspatial/geotiff/_writers/gpu.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ def write_geotiff_gpu(data: xr.DataArray | cupy.ndarray | np.ndarray,
4646
max_z_error: float = 0.0,
4747
streaming_buffer_bytes: int = 256 * 1024 * 1024,
4848
photometric: str | int = 'auto',
49-
allow_internal_only_jpeg: bool = False) -> None:
49+
allow_internal_only_jpeg: bool = False
50+
) -> str | BinaryIO:
5051
"""Write a CuPy-backed DataArray as a GeoTIFF with GPU compression.
5152
5253
Tiles are extracted and compressed on the GPU via nvCOMP, then
@@ -173,6 +174,14 @@ def write_geotiff_gpu(data: xr.DataArray | cupy.ndarray | np.ndarray,
173174
the flag, ``compression='jpeg'`` raises ``ValueError`` for
174175
parity with ``to_geotiff``. See issue #1845.
175176
177+
Returns
178+
-------
179+
str or binary file-like
180+
The ``path`` argument (a string for filesystem paths, the
181+
file-like object for BytesIO destinations). Returning the path
182+
mirrors ``to_geotiff`` and ``write_vrt`` so callers can handle
183+
the three writers uniformly. See issue #1938.
184+
176185
Raises
177186
------
178187
ValueError
@@ -502,5 +511,6 @@ def _gpu_compress_to_part(gpu_arr, w, h, spp):
502511
)
503512

504513
_write_bytes(file_bytes, path)
514+
return path
505515

506516

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Regression test for #1938: writer entry points return the written path.
2+
3+
``write_vrt`` returned ``str`` while ``to_geotiff`` and
4+
``write_geotiff_gpu`` returned ``None``. The drift broke ``mypy``
5+
consumers who handle the three writers uniformly and made the
6+
Sphinx-rendered docs surface inconsistent.
7+
8+
This module asserts:
9+
10+
1. ``to_geotiff`` returns the ``path`` argument for filesystem and
11+
file-like destinations.
12+
2. ``write_geotiff_gpu``'s annotation matches the canonical ``path``
13+
return (the runtime check is gated on cupy + CUDA availability and
14+
skipped here so the CPU test suite stays green).
15+
3. ``write_vrt`` keeps returning the path (already conformant).
16+
4. The three entry points share the same ``Returns`` annotation in
17+
``inspect.signature``.
18+
"""
19+
from __future__ import annotations
20+
21+
import importlib.util
22+
import inspect
23+
import io
24+
import os
25+
import tempfile
26+
27+
import numpy as np
28+
import pytest
29+
import xarray as xr
30+
31+
from xrspatial.geotiff import (
32+
to_geotiff,
33+
write_geotiff_gpu,
34+
write_vrt,
35+
)
36+
37+
38+
def _gpu_available() -> bool:
39+
if importlib.util.find_spec("cupy") is None:
40+
return False
41+
try:
42+
import cupy
43+
44+
return bool(cupy.cuda.is_available())
45+
except Exception:
46+
return False
47+
48+
49+
_HAS_GPU = _gpu_available()
50+
_gpu_only = pytest.mark.skipif(
51+
not _HAS_GPU, reason="cupy + CUDA required",
52+
)
53+
54+
55+
def _small_da() -> xr.DataArray:
56+
arr = np.arange(16, dtype=np.float32).reshape(4, 4)
57+
return xr.DataArray(
58+
arr,
59+
dims=("y", "x"),
60+
coords={"y": np.arange(4)[::-1].astype(np.float64),
61+
"x": np.arange(4).astype(np.float64)},
62+
attrs={"crs": 4326},
63+
)
64+
65+
66+
def test_to_geotiff_returns_string_path(tmp_path):
67+
"""``to_geotiff`` returns the str path passed in."""
68+
da = _small_da()
69+
out = tmp_path / "test_1938_str.tif"
70+
rv = to_geotiff(da, str(out))
71+
assert isinstance(rv, str), (
72+
f"to_geotiff(str) must return a str, got {type(rv).__name__}"
73+
)
74+
assert rv == str(out)
75+
assert os.path.exists(rv)
76+
77+
78+
def test_to_geotiff_returns_file_like(tmp_path):
79+
"""``to_geotiff`` returns the file-like object passed in."""
80+
da = _small_da()
81+
buf = io.BytesIO()
82+
rv = to_geotiff(da, buf)
83+
assert rv is buf, (
84+
f"to_geotiff(BytesIO) must return the same file-like, "
85+
f"got {type(rv).__name__}"
86+
)
87+
# The buffer was actually written to.
88+
assert buf.tell() > 0 or len(buf.getvalue()) > 0
89+
90+
91+
def test_to_geotiff_cog_returns_path(tmp_path):
92+
"""COG path also returns the str path."""
93+
da = _small_da()
94+
out = tmp_path / "test_1938_cog.tif"
95+
rv = to_geotiff(da, str(out), cog=True, tile_size=16)
96+
assert isinstance(rv, str)
97+
assert rv == str(out)
98+
assert os.path.exists(rv)
99+
100+
101+
def test_to_geotiff_dask_streaming_returns_path(tmp_path):
102+
"""Dask-streaming write path also returns the str path."""
103+
import dask.array as da_arr
104+
105+
arr = da_arr.arange(256, dtype=np.float32, chunks=64).reshape(16, 16)
106+
da = xr.DataArray(
107+
arr,
108+
dims=("y", "x"),
109+
coords={"y": np.arange(16)[::-1].astype(np.float64),
110+
"x": np.arange(16).astype(np.float64)},
111+
attrs={"crs": 4326},
112+
)
113+
out = tmp_path / "test_1938_dask.tif"
114+
rv = to_geotiff(da, str(out))
115+
assert isinstance(rv, str)
116+
assert rv == str(out)
117+
assert os.path.exists(rv)
118+
119+
120+
def test_write_vrt_returns_string_path(tmp_path):
121+
"""``write_vrt`` (already conformant) keeps returning the str path."""
122+
# Create a source tif first.
123+
src = tmp_path / "src.tif"
124+
to_geotiff(_small_da(), str(src))
125+
vrt_path = tmp_path / "out.vrt"
126+
rv = write_vrt(str(vrt_path), [str(src)])
127+
assert isinstance(rv, str)
128+
assert rv == str(vrt_path)
129+
assert os.path.exists(rv)
130+
131+
132+
@_gpu_only
133+
def test_write_geotiff_gpu_returns_string_path(tmp_path):
134+
"""GPU writer returns the str path (only runs with cupy + CUDA)."""
135+
import cupy
136+
137+
arr_cpu = np.arange(16, dtype=np.float32).reshape(4, 4)
138+
arr_gpu = cupy.asarray(arr_cpu)
139+
da = xr.DataArray(
140+
arr_gpu,
141+
dims=("y", "x"),
142+
coords={"y": np.arange(4)[::-1].astype(np.float64),
143+
"x": np.arange(4).astype(np.float64)},
144+
attrs={"crs": 4326},
145+
)
146+
out = tmp_path / "test_1938_gpu.tif"
147+
rv = write_geotiff_gpu(da, str(out))
148+
assert isinstance(rv, str)
149+
assert rv == str(out)
150+
assert os.path.exists(rv)
151+
152+
153+
def test_writer_signatures_declare_path_return():
154+
"""All three writers annotate the same return type.
155+
156+
The annotation is a string under ``from __future__ import annotations``;
157+
pin the literal so the three writers cannot drift apart silently.
158+
"""
159+
expected = {
160+
to_geotiff: "str | BinaryIO",
161+
write_geotiff_gpu: "str | BinaryIO",
162+
write_vrt: "str",
163+
}
164+
for fn, expected_ann in expected.items():
165+
sig = inspect.signature(fn)
166+
assert sig.return_annotation == expected_ann, (
167+
f"{fn.__name__} return annotation drifted: expected "
168+
f"{expected_ann!r}, got {sig.return_annotation!r}"
169+
)
170+
171+
172+
def test_writer_returns_are_not_none():
173+
"""None of the public writers may go back to returning ``None``."""
174+
da = _small_da()
175+
with tempfile.TemporaryDirectory() as td:
176+
out = os.path.join(td, "out.tif")
177+
rv = to_geotiff(da, out)
178+
assert rv is not None
179+
src = os.path.join(td, "src.tif")
180+
to_geotiff(da, src)
181+
vrt_rv = write_vrt(os.path.join(td, "m.vrt"), [src])
182+
assert vrt_rv is not None

0 commit comments

Comments
 (0)