Skip to content

Commit 6bd7463

Browse files
authored
Gate dict gdal_metadata behind the experimental rich-tag opt-in (#3327)
1 parent 60276ab commit 6bd7463

17 files changed

Lines changed: 142 additions & 27 deletions

docs/source/reference/geotiff_release_contract.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ category. The `Key` column matches the runtime key.
106106
| `writer.bigtiff` | advanced | `bigtiff=True` (or auto-promotion above 4 GiB) writes BigTIFF magic, 8-byte offsets, and 20-byte IFD entries. |
107107
| `writer.bigtiff_cog` | advanced | BigTIFF plus COG. Tracked separately because the combination has its own external-interop surface. |
108108
| `writer.gpu` | experimental | GPU write path. |
109-
| `writer.gdal_metadata_xml` | experimental | `attrs['gdal_metadata_xml']` is escaped before serialisation. |
109+
| `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). |
110110
| `writer.extra_tags` | experimental | Pass-through of TIFF tags outside the structured set via `attrs['extra_tags']`. |
111111
| `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). |
112112

xrspatial/geotiff/_attrs.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,11 @@ def _validate_write_rich_tag_optin(
531531
triggered: list[str] = []
532532
if attrs.get('gdal_metadata_xml') is not None:
533533
triggered.append("attrs['gdal_metadata_xml']")
534+
# ``_extract_rich_tags`` only builds GDAL XML from ``gdal_metadata``
535+
# when it is a dict (and ``gdal_metadata_xml`` is absent); a non-dict
536+
# value is ignored by the writer, so it does not need gating.
537+
if isinstance(attrs.get('gdal_metadata'), dict):
538+
triggered.append("attrs['gdal_metadata']")
534539
if attrs.get('extra_tags') is not None:
535540
triggered.append("attrs['extra_tags']")
536541
if gdal_metadata_xml_kwarg is not None:

xrspatial/geotiff/tests/attrs/test_contract.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,49 @@ def test_canonical_keys_present_per_backend(tmp_path, opener, label):
347347
)
348348

349349

350+
def _make_gdal_metadata_da():
351+
"""Fresh DataArray (no contract marker) carrying only a dict
352+
``gdal_metadata``. The writer turns this into on-disk GDAL XML, so it
353+
must trip the experimental rich-tag gate (#3320)."""
354+
data = np.arange(4, dtype=np.float32).reshape(2, 2)
355+
return xr.DataArray(
356+
data, dims=('y', 'x'),
357+
coords={'y': [240.0, 230.0], 'x': [100.0, 110.0]},
358+
attrs={'crs': 4326, 'gdal_metadata': {'AREA_OR_POINT': 'Area'}},
359+
)
360+
361+
362+
def test_gdal_metadata_dict_requires_optin_3320(tmp_path):
363+
"""A fresh DataArray whose only rich-tag attr is a dict
364+
``gdal_metadata`` writes GDAL XML to disk, so ``to_geotiff`` must
365+
reject it without ``allow_experimental_codecs`` and accept it with
366+
the flag (#3320)."""
367+
da = _make_gdal_metadata_da()
368+
rejected = str(tmp_path / 'gdal_metadata_optin_3320_rejected.tif')
369+
with pytest.raises(ValueError, match=r"attrs\['gdal_metadata'\]"):
370+
to_geotiff(da, rejected)
371+
assert not os.path.exists(rejected)
372+
373+
accepted = str(tmp_path / 'gdal_metadata_optin_3320_accepted.tif')
374+
to_geotiff(da, accepted, allow_experimental_codecs=True)
375+
assert os.path.exists(accepted)
376+
377+
378+
def test_gdal_metadata_dict_roundtrip_writes_flag_free_3320(tmp_path):
379+
"""A read-back DataArray carries the contract marker, so writing its
380+
``gdal_metadata`` back is the canonical round-trip and stays
381+
flag-free even without the opt-in (#3320)."""
382+
da = _make_gdal_metadata_da()
383+
src = str(tmp_path / 'gdal_metadata_roundtrip_3320_src.tif')
384+
to_geotiff(da, src, allow_experimental_codecs=True)
385+
386+
rd = open_geotiff(src)
387+
assert '_xrspatial_geotiff_contract' in rd.attrs
388+
out = str(tmp_path / 'gdal_metadata_roundtrip_3320_out.tif')
389+
to_geotiff(rd, out)
390+
assert os.path.exists(out)
391+
392+
350393
# ``raster_type`` lives outside the shared fixture because the canonical
351394
# default ('area') is encoded as *absence* in attrs. The two branches
352395
# need different fixtures.

xrspatial/geotiff/tests/gpu/test_writer.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,9 @@ def test_gdal_metadata_round_trips_via_gpu_writer(tmp_path):
301301
'CUSTOM_KEY': 'val_1563'}},
302302
)
303303
out = str(tmp_path / 'gdal_meta_1563.tif')
304-
_write_geotiff_gpu(da_gpu, out, compression='none')
304+
# gdal_metadata dict is an experimental rich-tag write (#3320).
305+
_write_geotiff_gpu(da_gpu, out, compression='none',
306+
allow_experimental_codecs=True)
305307

306308
rd = open_geotiff(out)
307309
meta = rd.attrs.get('gdal_metadata') or {}

xrspatial/geotiff/tests/read/test_mask_and_scale_dtype_parity_3066.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,15 @@ def _write(path, data, *, nodata=None, scale=None, offset=None):
2323
"SCALE": str(scale if scale is not None else 1.0),
2424
"OFFSET": str(offset if offset is not None else 0.0),
2525
}
26+
# gdal_metadata SCALE/OFFSET dict on a fresh array is an experimental
27+
# rich-tag write (#3320).
2628
to_geotiff(
2729
xr.DataArray(
2830
data, dims=("y", "x"),
2931
coords={"y": np.arange(h, 0, -1) - 0.5, "x": np.arange(w) + 0.5},
3032
attrs=attrs),
31-
path)
33+
path,
34+
allow_experimental_codecs="gdal_metadata" in attrs)
3235
return path
3336

3437

xrspatial/geotiff/tests/read/test_rioxarray_compat_2961.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ def _scale_offset_tiff(path, scale=2.0, offset=10.0, sentinel=255):
4848
"gdal_metadata": {"SCALE": str(scale), "OFFSET": str(offset)},
4949
},
5050
)
51-
to_geotiff(da, path)
51+
# gdal_metadata dict on a fresh array is an experimental rich-tag
52+
# write (#3320).
53+
to_geotiff(da, path, allow_experimental_codecs=True)
5254
return path
5355

5456

@@ -261,7 +263,9 @@ def _malformed_scale_tiff(path, scale="abc", offset="0"):
261263
"gdal_metadata": {"SCALE": scale, "OFFSET": offset},
262264
},
263265
)
264-
to_geotiff(da, path)
266+
# gdal_metadata dict on a fresh array is an experimental rich-tag
267+
# write (#3320).
268+
to_geotiff(da, path, allow_experimental_codecs=True)
265269
return path
266270

267271

@@ -486,7 +490,9 @@ def _per_band_scale_tiff(path, scales, offsets):
486490
},
487491
attrs={"crs": 4326, "gdal_metadata": meta},
488492
)
489-
to_geotiff(da, path)
493+
# gdal_metadata dict on a fresh array is an experimental rich-tag
494+
# write (#3320).
495+
to_geotiff(da, path, allow_experimental_codecs=True)
490496
return path
491497

492498

xrspatial/geotiff/tests/read/test_scale_zero_3104.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ def _scale_tiff(path, scale, offset="0", nodata=None):
3838
coords={"y": [1.5, 0.5], "x": [0.5, 1.5, 2.5]},
3939
attrs=attrs,
4040
)
41-
to_geotiff(da, path)
41+
# gdal_metadata dict on a fresh array is an experimental rich-tag
42+
# write (#3320).
43+
to_geotiff(da, path, allow_experimental_codecs=True)
4244
return path
4345

4446

xrspatial/geotiff/tests/release_gates/test_features.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1519,7 +1519,8 @@ def test_dataarray_attrs_round_trip(self, tmp_path):
15191519
attrs={'gdal_metadata': meta},
15201520
)
15211521
path = str(tmp_path / 'da_meta.tif')
1522-
to_geotiff(da, path, compression='none')
1522+
# gdal_metadata dict is an experimental rich-tag write (#3320).
1523+
to_geotiff(da, path, compression='none', allow_experimental_codecs=True)
15231524

15241525
result = open_geotiff(path)
15251526
assert result.attrs['gdal_metadata']['Source'] == 'test'

xrspatial/geotiff/tests/unit/test_safe_xml.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ def test_to_geotiff_special_chars_round_trip(self, tmp_path):
116116
}
117117

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

121122
opened = open_geotiff(str(path))
122123
round_tripped = opened.attrs.get("gdal_metadata")

xrspatial/geotiff/tests/unit/test_signatures.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -762,11 +762,48 @@ def test_validate_write_rich_tag_optin_rejects_extra_tags():
762762
)
763763

764764

765+
def test_validate_write_rich_tag_optin_rejects_gdal_metadata_dict():
766+
"""A dict ``attrs['gdal_metadata']`` is written to disk as XML by
767+
``_extract_rich_tags``; it must trip the same gate (#3320).
768+
"""
769+
with pytest.raises(ValueError, match=r"attrs\['gdal_metadata'\]"):
770+
_validate_write_rich_tag_optin(
771+
{'gdal_metadata': {'AREA_OR_POINT': 'Area'}},
772+
allow_experimental_codecs=False,
773+
)
774+
775+
776+
def test_validate_write_rich_tag_optin_names_both_xml_and_gdal_metadata():
777+
"""When both ``gdal_metadata_xml`` and a dict ``gdal_metadata`` are
778+
present without the opt-in, the rejection names both attrs (#3320)."""
779+
with pytest.raises(ValueError) as exc:
780+
_validate_write_rich_tag_optin(
781+
{'gdal_metadata_xml': '<GDALMetadata/>',
782+
'gdal_metadata': {'AREA_OR_POINT': 'Area'}},
783+
allow_experimental_codecs=False,
784+
)
785+
msg = str(exc.value)
786+
assert "attrs['gdal_metadata_xml']" in msg
787+
assert "attrs['gdal_metadata']" in msg
788+
789+
790+
def test_validate_write_rich_tag_optin_ignores_non_dict_gdal_metadata():
791+
"""Only a dict ``gdal_metadata`` builds XML in ``_extract_rich_tags``;
792+
a non-dict value is ignored by the writer, so the gate stays quiet
793+
(#3320).
794+
"""
795+
_validate_write_rich_tag_optin(
796+
{'gdal_metadata': 'not-a-dict'},
797+
allow_experimental_codecs=False,
798+
)
799+
800+
765801
def test_validate_write_rich_tag_optin_accepts_with_flag():
766-
"""``allow_experimental_codecs=True`` accepts both rich-tag attrs."""
802+
"""``allow_experimental_codecs=True`` accepts all rich-tag attrs."""
767803
_validate_write_rich_tag_optin(
768804
{'gdal_metadata_xml': '<GDALMetadata/>',
769-
'extra_tags': [(700, 1, 0, b'')]},
805+
'extra_tags': [(700, 1, 0, b'')],
806+
'gdal_metadata': {'AREA_OR_POINT': 'Area'}},
770807
allow_experimental_codecs=True,
771808
)
772809

@@ -780,6 +817,7 @@ def test_validate_write_rich_tag_optin_exempts_round_trip():
780817
_validate_write_rich_tag_optin(
781818
{'gdal_metadata_xml': '<GDALMetadata/>',
782819
'extra_tags': [(700, 1, 0, b'')],
820+
'gdal_metadata': {'AREA_OR_POINT': 'Area'},
783821
'_xrspatial_geotiff_contract': 2},
784822
allow_experimental_codecs=False,
785823
)

0 commit comments

Comments
 (0)