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
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ filterwarnings =
ignore:'asyncio.AbstractEventLoopPolicy' is deprecated:DeprecationWarning:pytest_asyncio
markers =
slow: long-running test cell (typical: golden-corpus fixtures behind a heavy codec or large pixel count). PR CI can skip with `-m "not slow"`; nightly / release runs use no filter. See xrspatial/geotiff/tests/golden_corpus/_marks.py for the corpus-side helper.
release_gate: locks a single stable feature in the GeoTIFF release contract (epic #2340). Always runs by default in CI; the marker exists so release engineers can run only these gates with `pytest -m release_gate` before tagging a release. Tests in this marker should be small, deterministic, and fail loudly if the contract breaks.

[isort]
line_length = 100
154 changes: 154 additions & 0 deletions xrspatial/geotiff/tests/test_release_gate_attrs_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Release gate: CRS / transform / nodata attrs contract (epic #2340).

The canonical attrs after a GeoTIFF read are tagged ``stable`` in the
release gate checklist. The contract: every georeferenced read produces
a DataArray whose ``attrs`` carry, at minimum, ``crs``, ``crs_wkt``,
``transform``, ``georef_status``, the contract version stamp, and (when
declared) ``nodata``. These attrs survive a write -> read round trip.

This file is the single-shot release gate. Deep canonicalisation,
alias handling, contract version bumps, and pass-through semantics are
each covered by their own ``test_attrs_contract_*_1984.py`` files; here
we lock the user-facing names and round-trip stability so the release
notes can quote the canonical attrs without caveats.

Out of scope:
* Alias handling (``test_attrs_contract_aliases_1984.py``).
* Attrs pass-through for user-supplied keys
(``test_attrs_contract_passthrough_1984.py``).
* Contract version stamp bump policy
(``test_attrs_contract_version_1984.py``).
"""
from __future__ import annotations

import numpy as np
import pytest

from xrspatial.geotiff import open_geotiff, to_geotiff
from xrspatial.geotiff._geotags import GeoTransform
from xrspatial.geotiff._writer import write


# Keys that release notes are allowed to promise on every georeferenced
# read. Adding a new key to the canonical set is a contract-version
# bump (see issue #1984); removing one is a breaking change. Anything
# else in the attrs (``masked_nodata``, ``nodata_pixels_present``,
# ``raster_type``, etc.) is additive and not pinned here.
CANONICAL_KEYS = (
"_xrspatial_geotiff_contract",
"crs",
"crs_wkt",
"transform",
"georef_status",
)


def _write_known_good(path: str, *, nodata: float | None = None) -> None:
arr = np.arange(16, dtype=np.float32).reshape(4, 4)
gt = GeoTransform(
origin_x=500000.0,
origin_y=4000000.0,
pixel_width=30.0,
pixel_height=-30.0,
)
write(
arr,
path,
geo_transform=gt,
crs_epsg=32610,
nodata=nodata,
compression="none",
tiled=False,
)


@pytest.mark.release_gate
def test_release_gate_attrs_canonical_keys_present(tmp_path) -> None:
"""A georeferenced read carries every canonical attrs key."""
path = str(tmp_path / "release_gate_attrs_canonical_2340.tif")
_write_known_good(path)

da = open_geotiff(path)
missing = [k for k in CANONICAL_KEYS if k not in da.attrs]
assert not missing, (
"release gate: canonical attrs keys missing from a georeferenced "
f"read: {missing}; release notes promise every key in "
f"{list(CANONICAL_KEYS)}"
)


@pytest.mark.release_gate
def test_release_gate_attrs_georef_status_full(tmp_path) -> None:
"""A fully-georeferenced read reports ``georef_status='full'``."""
path = str(tmp_path / "release_gate_attrs_georef_status_2340.tif")
_write_known_good(path)

da = open_geotiff(path)
status = da.attrs.get("georef_status")
assert status == "full", (
f"release gate: a CRS+transform read should report "
f"``georef_status='full'``; got {status!r}. The five canonical "
"georef_status values are the contract downstream code branches on"
)


@pytest.mark.release_gate
def test_release_gate_attrs_contract_version_is_int(tmp_path) -> None:
"""``attrs['_xrspatial_geotiff_contract']`` is an int.

The contract version is the downstream signal for which attrs
shape the array carries. A drift from int to string (or to a
Python object) would silently break callers that compare versions.
"""
path = str(tmp_path / "release_gate_attrs_contract_version_2340.tif")
_write_known_good(path)

da = open_geotiff(path)
version = da.attrs.get("_xrspatial_geotiff_contract")
assert isinstance(version, int), (
f"release gate: contract version stamp is not int: type="
f"{type(version).__name__}, value={version!r}"
)
assert version >= 1, (
f"release gate: contract version stamp is non-positive: {version!r}"
)


@pytest.mark.release_gate
def test_release_gate_attrs_round_trip_preserves_crs_transform_nodata(
tmp_path,
) -> None:
"""Canonical attrs survive a full ``write -> read -> write -> read`` cycle."""
src = str(tmp_path / "release_gate_attrs_rt_src_2340.tif")
_write_known_good(src, nodata=-9999.0)

first = open_geotiff(src)
crs_first = int(first.attrs["crs"])
transform_first = tuple(first.attrs["transform"])
nodata_first = float(first.attrs["nodata"])

# Round-trip through the public writer.
rewrite = str(tmp_path / "release_gate_attrs_rt_rewrite_2340.tif")
to_geotiff(first, rewrite, compression="none", tiled=False)

second = open_geotiff(rewrite)
assert int(second.attrs["crs"]) == crs_first, (
f"release gate: CRS drifted across round-trip: {crs_first} -> "
f"{second.attrs['crs']!r}"
)
transform_second = tuple(second.attrs["transform"])
assert len(transform_second) == 6, (
f"release gate: transform reshaped across round-trip: "
f"{transform_second!r}"
)
for got, want in zip(transform_second, transform_first):
assert got == pytest.approx(want, abs=1e-12, rel=1e-12), (
f"release gate: transform drifted across round-trip: "
f"{transform_first!r} -> {transform_second!r}"
)
assert float(second.attrs["nodata"]) == pytest.approx(
nodata_first, abs=0.0
), (
f"release gate: nodata drifted across round-trip: "
f"{nodata_first} -> {second.attrs['nodata']!r}"
)
132 changes: 132 additions & 0 deletions xrspatial/geotiff/tests/test_release_gate_codecs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Release gate: stable lossless codec round-trip (epic #2340).

The release contract for the GeoTIFF module names a specific set of
lossless codecs as ``stable``: ``none``, ``deflate``, ``lzw``,
``packbits``, ``zstd``. Every one of them must round-trip pixels
byte-for-byte through ``to_geotiff`` -> ``open_geotiff`` on both
integer and float dtypes.

This file is the per-codec gate: one parametrized test per dtype that
walks every stable codec. The fine-grained codec internals (LZW
dictionary edge cases, PackBits boundary cases, deflate stream framing,
etc.) live in their dedicated test files; here we only assert the
end-to-end public-API promise.

Out of scope: experimental codecs (``lerc``, ``jpeg2000``, ``j2k``,
``lz4``), the internal-only ``jpeg`` codec, and the COG layout gate
(see ``test_release_gate_cog.py``).
"""
from __future__ import annotations

import numpy as np
import pytest

from xrspatial.geotiff import SUPPORTED_FEATURES, open_geotiff
from xrspatial.geotiff._geotags import GeoTransform
from xrspatial.geotiff._writer import write


# The stable lossless codec set. Keep this list in lockstep with the
# ``codec.*`` entries tiered ``stable`` in
# :data:`xrspatial.geotiff.SUPPORTED_FEATURES`. If a codec is promoted
# into or out of stable, add or remove it here -- the gate is meant
# to lock the public-facing list.
STABLE_LOSSLESS_CODECS = ("none", "deflate", "lzw", "packbits", "zstd")


def _gt() -> GeoTransform:
return GeoTransform(
origin_x=500000.0,
origin_y=4000000.0,
pixel_width=30.0,
pixel_height=-30.0,
)


@pytest.mark.release_gate
@pytest.mark.parametrize("codec", STABLE_LOSSLESS_CODECS)
def test_release_gate_codec_round_trip_uint16(tmp_path, codec) -> None:
"""Integer pixel bytes survive every stable lossless codec."""
arr = np.arange(64, dtype=np.uint16).reshape(8, 8)
path = str(tmp_path / f"release_gate_codec_{codec}_uint16_2340.tif")
write(
arr,
path,
geo_transform=_gt(),
crs_epsg=32610,
compression=codec,
tiled=False,
)

out = open_geotiff(path)
assert out.dtype == np.uint16, (
f"release gate: codec {codec!r} promoted uint16 to {out.dtype!r}; "
"the lossless contract is that integer dtypes survive every "
"stable codec"
)
np.testing.assert_array_equal(
np.asarray(out.values),
arr,
err_msg=(
f"release gate: codec {codec!r} did not round-trip uint16 "
"pixels byte-for-byte; the release contract names this codec "
"as lossless"
),
)


@pytest.mark.release_gate
@pytest.mark.parametrize("codec", STABLE_LOSSLESS_CODECS)
def test_release_gate_codec_round_trip_float32(tmp_path, codec) -> None:
"""Float pixel bytes survive every stable lossless codec."""
# Use a deterministic but non-trivial pattern so a per-axis flip
# or per-row stride bug still fails.
arr = np.linspace(-100.0, 100.0, 64, dtype=np.float32).reshape(8, 8)
path = str(tmp_path / f"release_gate_codec_{codec}_float32_2340.tif")
write(
arr,
path,
geo_transform=_gt(),
crs_epsg=32610,
compression=codec,
tiled=False,
)

out = open_geotiff(path)
assert out.dtype == np.float32, (
f"release gate: codec {codec!r} promoted float32 to "
f"{out.dtype!r}"
)
np.testing.assert_array_equal(
np.asarray(out.values),
arr,
err_msg=(
f"release gate: codec {codec!r} did not round-trip float32 "
"pixels byte-for-byte; the release contract names this codec "
"as lossless"
),
)


@pytest.mark.release_gate
def test_release_gate_codec_stable_set_matches_supported_features() -> None:
"""The stable codec list in this file matches ``SUPPORTED_FEATURES``.

If a codec is promoted into ``stable`` (or demoted out) in
:data:`xrspatial.geotiff.SUPPORTED_FEATURES` without updating this
file, the release gate is out of sync with the runtime contract.
Fail loudly here so the PR that changes the tier also updates the
gate.
"""
stable_from_constant = {
key.split(".", 1)[1]
for key, tier in SUPPORTED_FEATURES.items()
if key.startswith("codec.") and tier == "stable"
}
assert stable_from_constant == set(STABLE_LOSSLESS_CODECS), (
"release gate: STABLE_LOSSLESS_CODECS drifted from "
"SUPPORTED_FEATURES; the gate and the runtime tier table must "
"agree on which codecs are stable. "
f"constant: {set(STABLE_LOSSLESS_CODECS)!r}; "
f"SUPPORTED_FEATURES: {stable_from_constant!r}"
)
Loading
Loading