Skip to content

Commit 577db26

Browse files
authored
Reject cog=True with tiled=False at the writer boundary (#2312) (#2318)
1 parent 35a0616 commit 577db26

3 files changed

Lines changed: 246 additions & 4 deletions

File tree

xrspatial/geotiff/_writer.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,28 @@
127127
# ---------------------------------------------------------------------------
128128

129129

130+
# ---------------------------------------------------------------------------
131+
# Shared error messages
132+
# ---------------------------------------------------------------------------
133+
134+
# Issue #2312: a single source of truth for the ``cog=True, tiled=False``
135+
# rejection message used by both the public ``to_geotiff`` boundary and
136+
# the array-level ``_write`` defense-in-depth gate. Keeping the message
137+
# string in one place stops the two raise sites from drifting if one
138+
# ever gets reworded. The substring assertions in
139+
# ``test_cog_requires_tiled_2312.py`` pin the actionable tokens
140+
# (``tiled=True``, ``cog=False``, ``COG``) so a future rewrite still
141+
# has to satisfy the same contract.
142+
_COG_REQUIRES_TILED_MSG = (
143+
"cog=True requires tiled=True: the COG specification "
144+
"mandates a tiled internal layout, so a strip-layout file "
145+
"cannot be a valid Cloud Optimized GeoTIFF. Pass tiled=True "
146+
"(or omit tiled, which defaults to True) to write a COG, or "
147+
"set cog=False to write a non-COG strip TIFF. See issue "
148+
"#2312."
149+
)
150+
151+
130152
# ---------------------------------------------------------------------------
131153
# Array-level write entry points (module-private; see module docstring)
132154
# ---------------------------------------------------------------------------
@@ -488,6 +510,18 @@ def _write(data: np.ndarray, path: str, *,
488510
if nodata is not None:
489511
nodata = _invert_nodata_for_miniswhite(nodata, data.dtype)
490512

513+
# Issue #2312: defense-in-depth gate for ``cog=True, tiled=False``.
514+
# The public ``to_geotiff`` wrapper rejects this combination at its
515+
# own boundary, so this branch is unreachable when the wrapper is
516+
# the caller; the gate matters for direct callers of ``_write`` and
517+
# for any future caller (test harness, internal tool) that bypasses
518+
# the wrapper. Without it, ``_write_stripped`` would run below and
519+
# the overview-pyramid block at line ~490 would attach overviews to
520+
# a strip-layout body, producing a malformed file that claims to be
521+
# a COG.
522+
if cog and not tiled:
523+
raise ValueError(_COG_REQUIRES_TILED_MSG)
524+
491525
# Build pixel data parts
492526
parts = []
493527

xrspatial/geotiff/_writers/eager.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from .._validation import (_validate_3d_writer_dims, _validate_no_rotated_affine,
3636
_validate_nodata_arg, _validate_tile_size_arg,
3737
_validate_writer_spatial_shape, validate_write_metadata)
38-
from .._writer import write
38+
from .._writer import _COG_REQUIRES_TILED_MSG, write
3939
from .gpu import write_geotiff_gpu
4040

4141

@@ -141,7 +141,9 @@ def to_geotiff(data: xr.DataArray | np.ndarray,
141141
Codecs without a level concept (lzw, packbits, jpeg) accept any
142142
value and ignore it.
143143
tiled : bool
144-
Use tiled layout (default True).
144+
Use tiled layout (default True). Incompatible with ``cog=True``
145+
because the COG specification requires a tiled internal layout;
146+
passing ``cog=True, tiled=False`` raises ``ValueError`` (#2312).
145147
tile_size : int
146148
Tile size in pixels (default 256). Must be a positive multiple
147149
of 16 when ``tiled=True``; this is a TIFF 6 spec requirement
@@ -160,7 +162,10 @@ def to_geotiff(data: xr.DataArray | np.ndarray,
160162
Advanced: COG output materialises the full array because
161163
overview pyramids need it, and the all-IFDs-at-file-start layout
162164
only round-trips through readers that honour the COG layout
163-
contract. Write as Cloud Optimized GeoTIFF.
165+
contract. Write as Cloud Optimized GeoTIFF. Requires
166+
``tiled=True`` (the default): the COG specification mandates a
167+
tiled internal layout, so ``cog=True, tiled=False`` raises
168+
``ValueError`` (#2312).
164169
overview_levels : list[int] or None
165170
Advanced: overview pyramids are an optional COG feature; the
166171
decimation factors and resampling choice affect downstream
@@ -565,11 +570,27 @@ def to_geotiff(data: xr.DataArray | np.ndarray,
565570
stacklevel=2,
566571
)
567572

573+
# Issue #2312: ``cog=True`` requires a tiled internal layout per the
574+
# COG spec. The writer used to accept ``cog=True, tiled=False``, warn
575+
# that ``tile_size`` was ignored, and then write strips via
576+
# ``_write`` -- silently producing a file that violates the stable
577+
# COG contract promoted in #2300. Reject the combination at the
578+
# public boundary with the same actionable-error shape as the other
579+
# COG input gates pinned in #2301 (commit f5fbad54): the message
580+
# names the violated constraint and lists both fixes the caller can
581+
# apply in one line. The defense-in-depth gate in ``_writer._write``
582+
# catches direct callers that bypass this wrapper.
583+
if cog and not tiled:
584+
raise ValueError(_COG_REQUIRES_TILED_MSG)
585+
568586
# tile_size only applies to tiled output; warn if the caller passed a
569587
# non-default size alongside strip mode (it would otherwise be silently
570588
# ignored). The VRT path always tiles, so the warning would be
571589
# misleading there -- the VRT branch below rejects tiled=False up front
572-
# instead.
590+
# instead. The ``cog=True, tiled=False`` arm of this warning is dead
591+
# under the #2312 gate above (that combination raises before reaching
592+
# this line), so the condition below only fires for ``cog=False,
593+
# tiled=False, tile_size != 256``.
573594
if not tiled and tile_size != 256 and not _is_vrt_path:
574595
warnings.warn(
575596
f"tile_size={tile_size} is ignored when tiled=False "
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""``cog=True`` requires ``tiled=True`` (#2312).
2+
3+
The COG specification mandates a tiled internal layout. Before this
4+
issue's fix, ``to_geotiff(..., cog=True, tiled=False)`` returned
5+
successfully and wrote a strip-layout TIFF: ``cog=True`` was silently
6+
ignored for the layout decision while the overview-pyramid and IFD-order
7+
parts of the COG path still ran, producing a malformed hybrid that
8+
violated the stable COG contract promoted in #2300.
9+
10+
These tests pin three things:
11+
12+
* The public ``to_geotiff`` wrapper rejects ``cog=True, tiled=False``
13+
with a typed, actionable error that names both fixes the caller can
14+
apply (``tiled=True`` or ``cog=False``). This is the user-visible
15+
rejection.
16+
* The defense-in-depth gate inside ``_writer._write`` also rejects the
17+
combination. Direct callers of the array-level entry point (the GPU
18+
CPU-fallback path, tests, internal tools) cannot bypass the public
19+
wrapper to produce the malformed file.
20+
* The tiled COG path (``cog=True``, default ``tiled=True``) still works
21+
end-to-end. A regression in the new gate that broke valid COG writes
22+
would be a worse outcome than the original bug.
23+
24+
Message-substring assertions mirror the style of
25+
``test_cog_invalid_input_errors_2286.py`` (PR #2301): every gate pins
26+
both the exception type and the actionable tokens (``tiled=True``,
27+
``cog=False``, ``COG``) so a future rewrite cannot silently turn the
28+
error into a vague one.
29+
"""
30+
from __future__ import annotations
31+
32+
import warnings
33+
34+
import numpy as np
35+
import pytest
36+
import xarray as xr
37+
38+
from xrspatial.geotiff import to_geotiff
39+
from xrspatial.geotiff._writer import write as _array_write
40+
41+
42+
# ---------------------------------------------------------------------------
43+
# Helpers
44+
# ---------------------------------------------------------------------------
45+
46+
def _float_da(shape=(64, 64)):
47+
"""A small float32 DataArray suitable for COG writes."""
48+
return xr.DataArray(
49+
np.zeros(shape, dtype=np.float32), dims=('y', 'x')
50+
)
51+
52+
53+
# ---------------------------------------------------------------------------
54+
# Public boundary: ``to_geotiff(cog=True, tiled=False)`` is refused.
55+
# ---------------------------------------------------------------------------
56+
57+
def test_public_writer_rejects_cog_true_tiled_false(tmp_path):
58+
"""The public entry point raises ``ValueError`` with a message that
59+
names the COG-spec constraint and both caller-side fixes."""
60+
da = _float_da()
61+
p = tmp_path / 'cog_tiled_false_2312.tif'
62+
63+
with pytest.raises(ValueError) as exc:
64+
to_geotiff(da, str(p), cog=True, tiled=False)
65+
66+
msg = str(exc.value)
67+
# The message must name the violated constraint.
68+
assert 'COG' in msg, msg
69+
assert 'tiled' in msg.lower(), msg
70+
# Both caller-side fixes must appear so the error is actionable.
71+
assert 'tiled=True' in msg, msg
72+
assert 'cog=False' in msg, msg
73+
74+
75+
def test_public_writer_rejects_cog_true_tiled_false_with_tile_size(tmp_path):
76+
"""Pinning the rejection survives a ``tile_size`` kwarg too.
77+
78+
Before #2312, ``to_geotiff(..., cog=True, tiled=False,
79+
tile_size=128)`` emitted the "tile_size is ignored when tiled=False"
80+
warning and then wrote strips. The new gate has to fire before that
81+
warning so the caller never sees the misleading "tile_size is
82+
ignored" message under ``cog=True``.
83+
"""
84+
da = _float_da()
85+
p = tmp_path / 'cog_tiled_false_with_tile_size_2312.tif'
86+
87+
# ``pytest.warns(None)`` was removed; use the stdlib catch_warnings
88+
# recorder to assert the dead "tile_size is ignored" warning never
89+
# fires on the ``cog=True`` arm.
90+
with warnings.catch_warnings(record=True) as record:
91+
warnings.simplefilter('always')
92+
with pytest.raises(ValueError) as exc:
93+
to_geotiff(da, str(p), cog=True, tiled=False, tile_size=128)
94+
95+
msg = str(exc.value)
96+
assert 'COG' in msg, msg
97+
assert 'tiled=True' in msg, msg
98+
99+
tile_size_warnings = [
100+
w for w in record
101+
if 'tile_size' in str(w.message)
102+
and 'is ignored when tiled=False' in str(w.message)
103+
]
104+
assert not tile_size_warnings, [str(w.message) for w in tile_size_warnings]
105+
106+
107+
# ---------------------------------------------------------------------------
108+
# Defense in depth: ``_writer._write(cog=True, tiled=False)`` also raises.
109+
# ---------------------------------------------------------------------------
110+
111+
def test_lowlevel_write_rejects_cog_true_tiled_false(tmp_path):
112+
"""The array-level entry point ``_writer._write`` (re-exported as
113+
``write``) carries its own gate so a caller that bypasses the public
114+
wrapper still gets the typed rejection.
115+
116+
Without this, a direct caller could quietly produce the malformed
117+
strip-plus-overviews file the public boundary refuses.
118+
"""
119+
arr = np.zeros((64, 64), dtype=np.float32)
120+
p = tmp_path / 'cog_tiled_false_lowlevel_2312.tif'
121+
122+
with pytest.raises(ValueError) as exc:
123+
_array_write(
124+
arr,
125+
str(p),
126+
compression='deflate',
127+
tiled=False,
128+
cog=True,
129+
)
130+
131+
msg = str(exc.value)
132+
assert 'COG' in msg, msg
133+
assert 'tiled=True' in msg, msg
134+
assert 'cog=False' in msg, msg
135+
136+
137+
# ---------------------------------------------------------------------------
138+
# Smoke test: the valid tiled COG path still works.
139+
# ---------------------------------------------------------------------------
140+
141+
def test_tiled_cog_smoke_still_works(tmp_path):
142+
"""A regression in the new gate that broke valid COG writes would
143+
be a worse outcome than the original bug. Pin the happy path
144+
end-to-end so the gate has to stay narrowly targeted at the
145+
``cog=True, tiled=False`` combination it is meant to catch.
146+
"""
147+
da = _float_da(shape=(128, 128))
148+
p = tmp_path / 'cog_tiled_smoke_2312.tif'
149+
150+
rv = to_geotiff(da, str(p), cog=True, tiled=True, tile_size=64)
151+
assert rv == str(p)
152+
assert p.exists()
153+
assert p.stat().st_size > 0
154+
155+
156+
def test_tiled_cog_smoke_default_tiled(tmp_path):
157+
"""``tiled`` defaults to ``True`` on ``to_geotiff``, so ``cog=True``
158+
on its own should also produce a valid COG. Pinned so a future
159+
change that flipped the default would not silently start hitting
160+
the new rejection gate.
161+
"""
162+
da = _float_da(shape=(128, 128))
163+
p = tmp_path / 'cog_tiled_default_smoke_2312.tif'
164+
165+
rv = to_geotiff(da, str(p), cog=True)
166+
assert rv == str(p)
167+
assert p.exists()
168+
assert p.stat().st_size > 0
169+
170+
171+
# ---------------------------------------------------------------------------
172+
# Negative control: ``cog=False, tiled=False`` is still a valid strip TIFF.
173+
# ---------------------------------------------------------------------------
174+
175+
def test_strip_layout_without_cog_still_works(tmp_path):
176+
"""``tiled=False`` without ``cog=True`` is the supported strip-TIFF
177+
path; the new gate must not regress it. Pinned so a stricter
178+
interpretation of ``cog=True implies tiled=True`` could not creep
179+
into the general ``tiled=False`` path.
180+
"""
181+
da = _float_da(shape=(64, 64))
182+
p = tmp_path / 'strip_no_cog_2312.tif'
183+
184+
rv = to_geotiff(da, str(p), cog=False, tiled=False)
185+
assert rv == str(p)
186+
assert p.exists()
187+
assert p.stat().st_size > 0

0 commit comments

Comments
 (0)