|
| 1 | +"""Release gate: CRS / transform / nodata attrs contract (epic #2340). |
| 2 | +
|
| 3 | +The canonical attrs after a GeoTIFF read are tagged ``stable`` in the |
| 4 | +release gate checklist. The contract: every georeferenced read produces |
| 5 | +a DataArray whose ``attrs`` carry, at minimum, ``crs``, ``crs_wkt``, |
| 6 | +``transform``, ``georef_status``, the contract version stamp, and (when |
| 7 | +declared) ``nodata``. These attrs survive a write -> read round trip. |
| 8 | +
|
| 9 | +This file is the single-shot release gate. Deep canonicalisation, |
| 10 | +alias handling, contract version bumps, and pass-through semantics are |
| 11 | +each covered by their own ``test_attrs_contract_*_1984.py`` files; here |
| 12 | +we lock the user-facing names and round-trip stability so the release |
| 13 | +notes can quote the canonical attrs without caveats. |
| 14 | +
|
| 15 | +Out of scope: |
| 16 | +* Alias handling (``test_attrs_contract_aliases_1984.py``). |
| 17 | +* Attrs pass-through for user-supplied keys |
| 18 | + (``test_attrs_contract_passthrough_1984.py``). |
| 19 | +* Contract version stamp bump policy |
| 20 | + (``test_attrs_contract_version_1984.py``). |
| 21 | +""" |
| 22 | +from __future__ import annotations |
| 23 | + |
| 24 | +import numpy as np |
| 25 | +import pytest |
| 26 | + |
| 27 | +from xrspatial.geotiff import open_geotiff, to_geotiff |
| 28 | +from xrspatial.geotiff._geotags import GeoTransform |
| 29 | +from xrspatial.geotiff._writer import write |
| 30 | + |
| 31 | + |
| 32 | +# Keys that release notes are allowed to promise on every georeferenced |
| 33 | +# read. Adding a new key to the canonical set is a contract-version |
| 34 | +# bump (see issue #1984); removing one is a breaking change. Anything |
| 35 | +# else in the attrs (``masked_nodata``, ``nodata_pixels_present``, |
| 36 | +# ``raster_type``, etc.) is additive and not pinned here. |
| 37 | +CANONICAL_KEYS = ( |
| 38 | + "_xrspatial_geotiff_contract", |
| 39 | + "crs", |
| 40 | + "crs_wkt", |
| 41 | + "transform", |
| 42 | + "georef_status", |
| 43 | +) |
| 44 | + |
| 45 | + |
| 46 | +def _write_known_good(path: str, *, nodata: float | None = None) -> None: |
| 47 | + arr = np.arange(16, dtype=np.float32).reshape(4, 4) |
| 48 | + gt = GeoTransform( |
| 49 | + origin_x=500000.0, |
| 50 | + origin_y=4000000.0, |
| 51 | + pixel_width=30.0, |
| 52 | + pixel_height=-30.0, |
| 53 | + ) |
| 54 | + write( |
| 55 | + arr, |
| 56 | + path, |
| 57 | + geo_transform=gt, |
| 58 | + crs_epsg=32610, |
| 59 | + nodata=nodata, |
| 60 | + compression="none", |
| 61 | + tiled=False, |
| 62 | + ) |
| 63 | + |
| 64 | + |
| 65 | +@pytest.mark.release_gate |
| 66 | +def test_release_gate_attrs_canonical_keys_present(tmp_path) -> None: |
| 67 | + """A georeferenced read carries every canonical attrs key.""" |
| 68 | + path = str(tmp_path / "release_gate_attrs_canonical_2340.tif") |
| 69 | + _write_known_good(path) |
| 70 | + |
| 71 | + da = open_geotiff(path) |
| 72 | + missing = [k for k in CANONICAL_KEYS if k not in da.attrs] |
| 73 | + assert not missing, ( |
| 74 | + "release gate: canonical attrs keys missing from a georeferenced " |
| 75 | + f"read: {missing}; release notes promise every key in " |
| 76 | + f"{list(CANONICAL_KEYS)}" |
| 77 | + ) |
| 78 | + |
| 79 | + |
| 80 | +@pytest.mark.release_gate |
| 81 | +def test_release_gate_attrs_georef_status_full(tmp_path) -> None: |
| 82 | + """A fully-georeferenced read reports ``georef_status='full'``.""" |
| 83 | + path = str(tmp_path / "release_gate_attrs_georef_status_2340.tif") |
| 84 | + _write_known_good(path) |
| 85 | + |
| 86 | + da = open_geotiff(path) |
| 87 | + status = da.attrs.get("georef_status") |
| 88 | + assert status == "full", ( |
| 89 | + f"release gate: a CRS+transform read should report " |
| 90 | + f"``georef_status='full'``; got {status!r}. The five canonical " |
| 91 | + "georef_status values are the contract downstream code branches on" |
| 92 | + ) |
| 93 | + |
| 94 | + |
| 95 | +@pytest.mark.release_gate |
| 96 | +def test_release_gate_attrs_contract_version_is_int(tmp_path) -> None: |
| 97 | + """``attrs['_xrspatial_geotiff_contract']`` is an int. |
| 98 | +
|
| 99 | + The contract version is the downstream signal for which attrs |
| 100 | + shape the array carries. A drift from int to string (or to a |
| 101 | + Python object) would silently break callers that compare versions. |
| 102 | + """ |
| 103 | + path = str(tmp_path / "release_gate_attrs_contract_version_2340.tif") |
| 104 | + _write_known_good(path) |
| 105 | + |
| 106 | + da = open_geotiff(path) |
| 107 | + version = da.attrs.get("_xrspatial_geotiff_contract") |
| 108 | + assert isinstance(version, int), ( |
| 109 | + f"release gate: contract version stamp is not int: type=" |
| 110 | + f"{type(version).__name__}, value={version!r}" |
| 111 | + ) |
| 112 | + assert version >= 1, ( |
| 113 | + f"release gate: contract version stamp is non-positive: {version!r}" |
| 114 | + ) |
| 115 | + |
| 116 | + |
| 117 | +@pytest.mark.release_gate |
| 118 | +def test_release_gate_attrs_round_trip_preserves_crs_transform_nodata( |
| 119 | + tmp_path, |
| 120 | +) -> None: |
| 121 | + """Canonical attrs survive a full ``write -> read -> write -> read`` cycle.""" |
| 122 | + src = str(tmp_path / "release_gate_attrs_rt_src_2340.tif") |
| 123 | + _write_known_good(src, nodata=-9999.0) |
| 124 | + |
| 125 | + first = open_geotiff(src) |
| 126 | + crs_first = int(first.attrs["crs"]) |
| 127 | + transform_first = tuple(first.attrs["transform"]) |
| 128 | + nodata_first = float(first.attrs["nodata"]) |
| 129 | + |
| 130 | + # Round-trip through the public writer. |
| 131 | + rewrite = str(tmp_path / "release_gate_attrs_rt_rewrite_2340.tif") |
| 132 | + to_geotiff(first, rewrite, compression="none", tiled=False) |
| 133 | + |
| 134 | + second = open_geotiff(rewrite) |
| 135 | + assert int(second.attrs["crs"]) == crs_first, ( |
| 136 | + f"release gate: CRS drifted across round-trip: {crs_first} -> " |
| 137 | + f"{second.attrs['crs']!r}" |
| 138 | + ) |
| 139 | + transform_second = tuple(second.attrs["transform"]) |
| 140 | + assert len(transform_second) == 6, ( |
| 141 | + f"release gate: transform reshaped across round-trip: " |
| 142 | + f"{transform_second!r}" |
| 143 | + ) |
| 144 | + for got, want in zip(transform_second, transform_first): |
| 145 | + assert got == pytest.approx(want, abs=1e-12, rel=1e-12), ( |
| 146 | + f"release gate: transform drifted across round-trip: " |
| 147 | + f"{transform_first!r} -> {transform_second!r}" |
| 148 | + ) |
| 149 | + assert float(second.attrs["nodata"]) == pytest.approx( |
| 150 | + nodata_first, abs=0.0 |
| 151 | + ), ( |
| 152 | + f"release gate: nodata drifted across round-trip: " |
| 153 | + f"{nodata_first} -> {second.attrs['nodata']!r}" |
| 154 | + ) |
0 commit comments