|
| 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