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
2 changes: 1 addition & 1 deletion docs/source/reference/geotiff_release_contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ category. The `Key` column matches the runtime key.
| `writer.bigtiff` | advanced | `bigtiff=True` (or auto-promotion above 4 GiB) writes BigTIFF magic, 8-byte offsets, and 20-byte IFD entries. |
| `writer.bigtiff_cog` | advanced | BigTIFF plus COG. Tracked separately because the combination has its own external-interop surface. |
| `writer.gpu` | experimental | GPU write path. |
| `writer.gdal_metadata_xml` | experimental | `attrs['gdal_metadata_xml']` is escaped before serialisation. |
| `writer.gdal_metadata_xml` | experimental | `attrs['gdal_metadata_xml']` is escaped before serialisation. A dict `attrs['gdal_metadata']` rides the same gate: writers build the XML from it, so a fresh DataArray carrying it also requires `allow_experimental_codecs=True` (#3320). |
| `writer.extra_tags` | experimental | Pass-through of TIFF tags outside the structured set via `attrs['extra_tags']`. |
| `writer.pack` | experimental | `pack=True` on `to_geotiff`; inverse of `reader.unpack`. Re-applies the recorded scale/offset, restores the integer source dtype, and fills NaN back to the nodata sentinel. Tracked at the same tier as `reader.unpack` (#3112, #3114). |

Expand Down
5 changes: 5 additions & 0 deletions xrspatial/geotiff/_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,11 @@ def _validate_write_rich_tag_optin(
triggered: list[str] = []
if attrs.get('gdal_metadata_xml') is not None:
triggered.append("attrs['gdal_metadata_xml']")
# ``_extract_rich_tags`` only builds GDAL XML from ``gdal_metadata``
# when it is a dict (and ``gdal_metadata_xml`` is absent); a non-dict
# value is ignored by the writer, so it does not need gating.
if isinstance(attrs.get('gdal_metadata'), dict):
triggered.append("attrs['gdal_metadata']")
if attrs.get('extra_tags') is not None:
triggered.append("attrs['extra_tags']")
if gdal_metadata_xml_kwarg is not None:
Expand Down
43 changes: 43 additions & 0 deletions xrspatial/geotiff/tests/attrs/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,49 @@ def test_canonical_keys_present_per_backend(tmp_path, opener, label):
)


def _make_gdal_metadata_da():
"""Fresh DataArray (no contract marker) carrying only a dict
``gdal_metadata``. The writer turns this into on-disk GDAL XML, so it
must trip the experimental rich-tag gate (#3320)."""
data = np.arange(4, dtype=np.float32).reshape(2, 2)
return xr.DataArray(
data, dims=('y', 'x'),
coords={'y': [240.0, 230.0], 'x': [100.0, 110.0]},
attrs={'crs': 4326, 'gdal_metadata': {'AREA_OR_POINT': 'Area'}},
)


def test_gdal_metadata_dict_requires_optin_3320(tmp_path):
"""A fresh DataArray whose only rich-tag attr is a dict
``gdal_metadata`` writes GDAL XML to disk, so ``to_geotiff`` must
reject it without ``allow_experimental_codecs`` and accept it with
the flag (#3320)."""
da = _make_gdal_metadata_da()
rejected = str(tmp_path / 'gdal_metadata_optin_3320_rejected.tif')
with pytest.raises(ValueError, match=r"attrs\['gdal_metadata'\]"):
to_geotiff(da, rejected)
assert not os.path.exists(rejected)

accepted = str(tmp_path / 'gdal_metadata_optin_3320_accepted.tif')
to_geotiff(da, accepted, allow_experimental_codecs=True)
assert os.path.exists(accepted)


def test_gdal_metadata_dict_roundtrip_writes_flag_free_3320(tmp_path):
"""A read-back DataArray carries the contract marker, so writing its
``gdal_metadata`` back is the canonical round-trip and stays
flag-free even without the opt-in (#3320)."""
da = _make_gdal_metadata_da()
src = str(tmp_path / 'gdal_metadata_roundtrip_3320_src.tif')
to_geotiff(da, src, allow_experimental_codecs=True)

rd = open_geotiff(src)
assert '_xrspatial_geotiff_contract' in rd.attrs
out = str(tmp_path / 'gdal_metadata_roundtrip_3320_out.tif')
to_geotiff(rd, out)
assert os.path.exists(out)


# ``raster_type`` lives outside the shared fixture because the canonical
# default ('area') is encoded as *absence* in attrs. The two branches
# need different fixtures.
Expand Down
4 changes: 3 additions & 1 deletion xrspatial/geotiff/tests/gpu/test_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,9 @@ def test_gdal_metadata_round_trips_via_gpu_writer(tmp_path):
'CUSTOM_KEY': 'val_1563'}},
)
out = str(tmp_path / 'gdal_meta_1563.tif')
_write_geotiff_gpu(da_gpu, out, compression='none')
# gdal_metadata dict is an experimental rich-tag write (#3320).
_write_geotiff_gpu(da_gpu, out, compression='none',
allow_experimental_codecs=True)

rd = open_geotiff(out)
meta = rd.attrs.get('gdal_metadata') or {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@ def _write(path, data, *, nodata=None, scale=None, offset=None):
"SCALE": str(scale if scale is not None else 1.0),
"OFFSET": str(offset if offset is not None else 0.0),
}
# gdal_metadata SCALE/OFFSET dict on a fresh array is an experimental
# rich-tag write (#3320).
to_geotiff(
xr.DataArray(
data, dims=("y", "x"),
coords={"y": np.arange(h, 0, -1) - 0.5, "x": np.arange(w) + 0.5},
attrs=attrs),
path)
path,
allow_experimental_codecs="gdal_metadata" in attrs)
return path


Expand Down
12 changes: 9 additions & 3 deletions xrspatial/geotiff/tests/read/test_rioxarray_compat_2961.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ def _scale_offset_tiff(path, scale=2.0, offset=10.0, sentinel=255):
"gdal_metadata": {"SCALE": str(scale), "OFFSET": str(offset)},
},
)
to_geotiff(da, path)
# gdal_metadata dict on a fresh array is an experimental rich-tag
# write (#3320).
to_geotiff(da, path, allow_experimental_codecs=True)
return path


Expand Down Expand Up @@ -261,7 +263,9 @@ def _malformed_scale_tiff(path, scale="abc", offset="0"):
"gdal_metadata": {"SCALE": scale, "OFFSET": offset},
},
)
to_geotiff(da, path)
# gdal_metadata dict on a fresh array is an experimental rich-tag
# write (#3320).
to_geotiff(da, path, allow_experimental_codecs=True)
return path


Expand Down Expand Up @@ -486,7 +490,9 @@ def _per_band_scale_tiff(path, scales, offsets):
},
attrs={"crs": 4326, "gdal_metadata": meta},
)
to_geotiff(da, path)
# gdal_metadata dict on a fresh array is an experimental rich-tag
# write (#3320).
to_geotiff(da, path, allow_experimental_codecs=True)
return path


Expand Down
4 changes: 3 additions & 1 deletion xrspatial/geotiff/tests/read/test_scale_zero_3104.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ def _scale_tiff(path, scale, offset="0", nodata=None):
coords={"y": [1.5, 0.5], "x": [0.5, 1.5, 2.5]},
attrs=attrs,
)
to_geotiff(da, path)
# gdal_metadata dict on a fresh array is an experimental rich-tag
# write (#3320).
to_geotiff(da, path, allow_experimental_codecs=True)
return path


Expand Down
3 changes: 2 additions & 1 deletion xrspatial/geotiff/tests/release_gates/test_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -1519,7 +1519,8 @@ def test_dataarray_attrs_round_trip(self, tmp_path):
attrs={'gdal_metadata': meta},
)
path = str(tmp_path / 'da_meta.tif')
to_geotiff(da, path, compression='none')
# gdal_metadata dict is an experimental rich-tag write (#3320).
to_geotiff(da, path, compression='none', allow_experimental_codecs=True)

result = open_geotiff(path)
assert result.attrs['gdal_metadata']['Source'] == 'test'
Expand Down
3 changes: 2 additions & 1 deletion xrspatial/geotiff/tests/unit/test_safe_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ def test_to_geotiff_special_chars_round_trip(self, tmp_path):
}

path = tmp_path / "meta.tif"
to_geotiff(da, str(path))
# gdal_metadata dict is an experimental rich-tag write (#3320).
to_geotiff(da, str(path), allow_experimental_codecs=True)

opened = open_geotiff(str(path))
round_tripped = opened.attrs.get("gdal_metadata")
Expand Down
42 changes: 40 additions & 2 deletions xrspatial/geotiff/tests/unit/test_signatures.py
Original file line number Diff line number Diff line change
Expand Up @@ -762,11 +762,48 @@ def test_validate_write_rich_tag_optin_rejects_extra_tags():
)


def test_validate_write_rich_tag_optin_rejects_gdal_metadata_dict():
"""A dict ``attrs['gdal_metadata']`` is written to disk as XML by
``_extract_rich_tags``; it must trip the same gate (#3320).
"""
with pytest.raises(ValueError, match=r"attrs\['gdal_metadata'\]"):
_validate_write_rich_tag_optin(
{'gdal_metadata': {'AREA_OR_POINT': 'Area'}},
allow_experimental_codecs=False,
)


def test_validate_write_rich_tag_optin_names_both_xml_and_gdal_metadata():
"""When both ``gdal_metadata_xml`` and a dict ``gdal_metadata`` are
present without the opt-in, the rejection names both attrs (#3320)."""
with pytest.raises(ValueError) as exc:
_validate_write_rich_tag_optin(
{'gdal_metadata_xml': '<GDALMetadata/>',
'gdal_metadata': {'AREA_OR_POINT': 'Area'}},
allow_experimental_codecs=False,
)
msg = str(exc.value)
assert "attrs['gdal_metadata_xml']" in msg
assert "attrs['gdal_metadata']" in msg


def test_validate_write_rich_tag_optin_ignores_non_dict_gdal_metadata():
"""Only a dict ``gdal_metadata`` builds XML in ``_extract_rich_tags``;
a non-dict value is ignored by the writer, so the gate stays quiet
(#3320).
"""
_validate_write_rich_tag_optin(
{'gdal_metadata': 'not-a-dict'},
allow_experimental_codecs=False,
)


def test_validate_write_rich_tag_optin_accepts_with_flag():
"""``allow_experimental_codecs=True`` accepts both rich-tag attrs."""
"""``allow_experimental_codecs=True`` accepts all rich-tag attrs."""
_validate_write_rich_tag_optin(
{'gdal_metadata_xml': '<GDALMetadata/>',
'extra_tags': [(700, 1, 0, b'')]},
'extra_tags': [(700, 1, 0, b'')],
'gdal_metadata': {'AREA_OR_POINT': 'Area'}},
allow_experimental_codecs=True,
)

Expand All @@ -780,6 +817,7 @@ def test_validate_write_rich_tag_optin_exempts_round_trip():
_validate_write_rich_tag_optin(
{'gdal_metadata_xml': '<GDALMetadata/>',
'extra_tags': [(700, 1, 0, b'')],
'gdal_metadata': {'AREA_OR_POINT': 'Area'},
'_xrspatial_geotiff_contract': 2},
allow_experimental_codecs=False,
)
Expand Down
16 changes: 9 additions & 7 deletions xrspatial/geotiff/tests/vrt/test_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -831,7 +831,8 @@ class TestVrtTiledMetadataParity:
def test_nodatavals_alias_propagates_to_tiles(self, tmp_path):
da = _tiled_metadata_make_rioxarray_style()
vrt = str(tmp_path / 'nodatavals.vrt')
to_geotiff(da, vrt, tile_size=16)
# gdal_metadata dict is an experimental rich-tag write (#3320).
to_geotiff(da, vrt, tile_size=16, allow_experimental_codecs=True)
tile_da = open_geotiff(_tiled_metadata_first_tile_path(vrt))
assert tile_da.attrs.get('nodata') == -9999.0

Expand All @@ -847,15 +848,15 @@ def test_fill_value_alias_propagates_to_tiles(self, tmp_path):
def test_gdal_metadata_propagates_to_tiles(self, tmp_path):
da = _tiled_metadata_make_rioxarray_style()
vrt = str(tmp_path / 'gdal_meta.vrt')
to_geotiff(da, vrt, tile_size=16)
to_geotiff(da, vrt, tile_size=16, allow_experimental_codecs=True)
tile_da = open_geotiff(_tiled_metadata_first_tile_path(vrt))
gm = tile_da.attrs.get('gdal_metadata')
assert gm == {'AREA_OR_POINT': 'Area', 'foo': 'bar'}

def test_resolution_tags_propagate_to_tiles(self, tmp_path):
da = _tiled_metadata_make_rioxarray_style()
vrt = str(tmp_path / 'resolution.vrt')
to_geotiff(da, vrt, tile_size=16)
to_geotiff(da, vrt, tile_size=16, allow_experimental_codecs=True)
tile_da = open_geotiff(_tiled_metadata_first_tile_path(vrt))
assert tile_da.attrs.get('x_resolution') == 96.0
assert tile_da.attrs.get('y_resolution') == 96.0
Expand All @@ -864,7 +865,7 @@ def test_resolution_tags_propagate_to_tiles(self, tmp_path):
def test_raster_type_point_propagates_to_tiles(self, tmp_path):
da = _tiled_metadata_make_rioxarray_style()
vrt = str(tmp_path / 'point.vrt')
to_geotiff(da, vrt, tile_size=16)
to_geotiff(da, vrt, tile_size=16, allow_experimental_codecs=True)
tile_da = open_geotiff(_tiled_metadata_first_tile_path(vrt))
assert tile_da.attrs.get('raster_type') == 'point'

Expand All @@ -873,8 +874,8 @@ def test_tif_vs_vrt_tile_metadata_parity(self, tmp_path):
da = _tiled_metadata_make_rioxarray_style()
tif_path = str(tmp_path / 'parity.tif')
vrt_path = str(tmp_path / 'parity.vrt')
to_geotiff(da, tif_path, tile_size=16)
to_geotiff(da, vrt_path, tile_size=16)
to_geotiff(da, tif_path, tile_size=16, allow_experimental_codecs=True)
to_geotiff(da, vrt_path, tile_size=16, allow_experimental_codecs=True)
tif_da = open_geotiff(tif_path)
tile_da = open_geotiff(_tiled_metadata_first_tile_path(vrt_path))
keys = ('nodata', 'gdal_metadata', 'raster_type', 'x_resolution', 'y_resolution', 'resolution_unit') # noqa: E501
Expand Down Expand Up @@ -934,7 +935,8 @@ def test_nodatavals_alias_dask(self, tmp_path):
da_np = xr.DataArray(arr, dims=('y', 'x'), coords={'y': np.arange(8.0), 'x': np.arange(8.0)}, attrs={'nodatavals': (-9999.0,), 'crs': 4326, 'gdal_metadata': {'k': 'v'}}) # noqa: E501
da = xr.DataArray(dska.from_array(arr, chunks=4), dims=da_np.dims, coords=da_np.coords, attrs=da_np.attrs) # noqa: E501
vrt = str(tmp_path / 'dask.vrt')
to_geotiff(da, vrt, tile_size=16)
# gdal_metadata dict is an experimental rich-tag write (#3320).
to_geotiff(da, vrt, tile_size=16, allow_experimental_codecs=True)
tile_da = open_geotiff(_tiled_metadata_first_tile_path(vrt))
assert tile_da.attrs.get('nodata') == -9999.0
assert tile_da.attrs.get('gdal_metadata') == {'k': 'v'}
Expand Down
4 changes: 3 additions & 1 deletion xrspatial/geotiff/tests/write/test_pack_3064.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ def _write_int_tiff(path, data, *, nodata=None, scale=None, offset=None):
coords={"y": np.arange(h, 0, -1) - 0.5, "x": np.arange(w) + 0.5},
attrs=attrs,
)
to_geotiff(da, path)
# A gdal_metadata SCALE/OFFSET dict on a fresh array is an
# experimental rich-tag write (#3320).
to_geotiff(da, path, allow_experimental_codecs="gdal_metadata" in attrs)
return path


Expand Down
4 changes: 3 additions & 1 deletion xrspatial/geotiff/tests/write/test_pack_band_subset_3161.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ def _write_two_band_tiff(path, *, gdal_metadata, nodata=65535):
coords={"y": [1.5, 0.5], "x": [0.5, 1.5, 2.5]},
attrs={"crs": 4326, "nodata": nodata, "gdal_metadata": gdal_metadata},
)
to_geotiff(da, path)
# gdal_metadata dict on a fresh array is an experimental rich-tag
# write (#3320).
to_geotiff(da, path, allow_experimental_codecs=True)
return path, data


Expand Down
4 changes: 3 additions & 1 deletion xrspatial/geotiff/tests/write/test_pack_float_width_3080.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ def _write_f32_tiff(path, data, *, nodata=None, scale=None, offset=None):
coords={"y": np.arange(h, 0, -1) - 0.5, "x": np.arange(w) + 0.5},
attrs=attrs,
)
to_geotiff(da, path)
# A gdal_metadata SCALE/OFFSET dict on a fresh array is an
# experimental rich-tag write (#3320).
to_geotiff(da, path, allow_experimental_codecs="gdal_metadata" in attrs)
return path


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ def _write_packed_no_sentinel_tiff(path, data, *, scale="0.1", offset="5.0"):
attrs={"crs": 4326,
"gdal_metadata": {"SCALE": scale, "OFFSET": offset}},
)
to_geotiff(da, str(path))
# gdal_metadata dict on a fresh array is an experimental rich-tag
# write (#3320).
to_geotiff(da, str(path), allow_experimental_codecs=True)
return str(path)


Expand Down
4 changes: 3 additions & 1 deletion xrspatial/geotiff/tests/write/test_pack_nodata_kwarg_3168.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ def _write_int_tiff(path, data, *, nodata=None, scale=None, offset=None):
coords={"y": np.arange(h, 0, -1) - 0.5, "x": np.arange(w) + 0.5},
attrs=attrs,
)
to_geotiff(da, path)
# A gdal_metadata SCALE/OFFSET dict on a fresh array is an
# experimental rich-tag write (#3320).
to_geotiff(da, path, allow_experimental_codecs="gdal_metadata" in attrs)
return path


Expand Down
10 changes: 6 additions & 4 deletions xrspatial/geotiff/tests/write/test_pack_range_guard_3260.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ def _write_scaled_int16(path, *, nodata=None):
coords={"y": [1.5, 0.5], "x": [0.5, 1.5]},
attrs=attrs,
)
to_geotiff(da, str(path), nodata=nodata)
to_geotiff(da, str(path), nodata=nodata,
allow_experimental_codecs=True)
return str(path)


Expand Down Expand Up @@ -79,7 +80,7 @@ def test_pack_rejects_underflow_unsigned(tmp_path, chunks):
"gdal_metadata": {"SCALE": "0.5", "OFFSET": "0.0"}},
)
src = str(tmp_path / "src_u16_underflow_3260.tif")
to_geotiff(da, src)
to_geotiff(da, src, allow_experimental_codecs=True)

mod = _unpacked(src)
mod.data[0, 0] = -1.0 # packs to -2, below uint16 min
Expand Down Expand Up @@ -127,7 +128,7 @@ def test_pack_accepts_full_dtype_range(tmp_path, chunks):
"gdal_metadata": {"SCALE": "0.1", "OFFSET": "0.0"}},
)
src = str(tmp_path / "src_bounds_3260.tif")
to_geotiff(da, src)
to_geotiff(da, src, allow_experimental_codecs=True)

mod = _unpacked(src)
if chunks is not None:
Expand Down Expand Up @@ -163,7 +164,8 @@ def test_pack_float_target_not_range_guarded(tmp_path):
"gdal_metadata": {"SCALE": "2.0", "OFFSET": "0.0"}},
)
src = str(tmp_path / "src_float_3260.tif")
to_geotiff(da, src, nodata=-9999.0)
to_geotiff(da, src, nodata=-9999.0,
allow_experimental_codecs=True)

mod = _unpacked(src)
mod.data[0, 0] = 1e30
Expand Down
Loading