Skip to content

Commit 7b65bef

Browse files
committed
geotiff: deprecate geographic-CRS GeoKey attrs in DataArray.attrs (#1984)
The six geographic-CRS GeoKey-derived attrs (crs_name, geog_citation, datum_code, angular_units, semi_major_axis, inv_flattening) were documented in the attrs contract as best-effort pass-through, but the PR6 locking test (#2004) showed they never round-trip: build_geo_tags only emits the primary GEOKEY_GEOGRAPHIC_TYPE, so the secondary GeoKeys these attrs come from are never written back. PR 7 of the issue #1984 plan deprecates the read-side emission. Each emission now fires a DeprecationWarning with a stable wording. The values still land in attrs for one release cycle so external callers can migrate; removal is scheduled for a later release. Changes: - _attrs.py: add _DEPRECATED_GEOGRAPHIC_GEOKEY_ATTRS tuple, _deprecated_geographic_geokey_warning text helper, and _emit_deprecated_geographic_geokey emission helper. Route the six attrs through the helper in _populate_attrs_from_geo_info. - _attrs.py docstring: move the six attrs out of the best-effort pass-through tier and into a new "Deprecated" tier section. - New test_attrs_pr7_deprecate_geographic_1984.py with parametrised warning assertions, still-emits-during-deprecation assertions, a no-warning-when-field-absent guard, and a warning-text shape check. - CHANGELOG.md: document the deprecation under Unreleased.
1 parent 198a397 commit 7b65bef

3 files changed

Lines changed: 238 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Unreleased
66

77
#### Bug fixes and improvements
8+
- Deprecate read-side emission of geographic-CRS GeoKey attrs (crs_name, geog_citation, datum_code, angular_units, semi_major_axis, inv_flattening); the writer cannot reconstruct them so they do not round-trip. These attrs still emit for now but trigger a DeprecationWarning. Removal planned for a future release. (#1984)
89
- Refresh the geotiff mmap cache when a file is replaced under the same path so re-reads after an atomic-rename overwrite no longer return stale bytes
910
- Decode TIFF predictor=3 un-transpose by file byte order so big-endian floating-point TIFFs read back exactly
1011
- Default internal `_vrt.read_vrt` `missing_sources` to `'raise'` so an unreadable VRT source no longer produces a silent zero-fill hole on integer rasters; pass `missing_sources='warn'` to opt back into the previous lenient behaviour (#1843)

xrspatial/geotiff/_attrs.py

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,7 @@
6161
Best-effort pass-through (preserved when the writer can reconstruct
6262
from canonical state, otherwise dropped on round-trip):
6363
64-
- ``crs_name``: human-readable CRS name from the GeoKey directory.
65-
- ``geog_citation``: GeographicTypeGeoKey citation string.
66-
- ``datum_code``: GeogGeodeticDatumGeoKey value.
67-
- ``angular_units``: GeogAngularUnitsGeoKey value.
6864
- ``linear_units``: ProjLinearUnitsGeoKey value.
69-
- ``semi_major_axis``: GeogSemiMajorAxisGeoKey value.
70-
- ``inv_flattening``: GeogInvFlatteningGeoKey value.
7165
- ``projection_code``: ProjectedCSTypeGeoKey value.
7266
- ``vertical_crs``: VerticalCSTypeGeoKey value.
7367
- ``vertical_citation``: VerticalCitationGeoKey value.
@@ -76,6 +70,22 @@
7670
- ``extra_samples``: TIFF ExtraSamples tag.
7771
- ``colormap``, ``colormap_rgba``, ``cmap``: palette data attached to
7872
single-band paletted images.
73+
74+
Deprecated (will be removed in a future release; see issue #1984):
75+
76+
These attrs are still emitted on read for one release cycle, but each
77+
emission triggers a ``DeprecationWarning``. The writer's
78+
``build_geo_tags`` only emits the primary geographic GeoKey, so the
79+
secondary GeoKeys these attrs are derived from are never written and
80+
the values do not survive a write -> read round-trip. Callers should
81+
stop relying on them and use ``crs`` / ``crs_wkt`` instead.
82+
83+
- ``crs_name``: human-readable CRS name from the GeoKey directory.
84+
- ``geog_citation``: GeographicTypeGeoKey citation string.
85+
- ``datum_code``: GeogGeodeticDatumGeoKey value.
86+
- ``angular_units``: GeogAngularUnitsGeoKey value.
87+
- ``semi_major_axis``: GeogSemiMajorAxisGeoKey value.
88+
- ``inv_flattening``: GeogInvFlatteningGeoKey value.
7989
"""
8090
from __future__ import annotations
8191

@@ -125,6 +135,52 @@
125135
_RESOLUTION_UNIT_IDS = {'none': 1, 'inch': 2, 'centimeter': 3}
126136

127137

138+
# Geographic-CRS GeoKey-derived attrs scheduled for removal (issue #1984
139+
# PR 7). The writer's ``build_geo_tags`` only emits the primary
140+
# GEOKEY_GEOGRAPHIC_TYPE, never the secondary geographic GeoKeys these
141+
# attrs are derived from. The values therefore never round-trip. Keep
142+
# emitting them for one release cycle so external callers can migrate,
143+
# then drop the emission entirely.
144+
_DEPRECATED_GEOGRAPHIC_GEOKEY_ATTRS = (
145+
'crs_name',
146+
'geog_citation',
147+
'datum_code',
148+
'angular_units',
149+
'semi_major_axis',
150+
'inv_flattening',
151+
)
152+
153+
154+
def _deprecated_geographic_geokey_warning(name: str) -> str:
155+
"""Warning text shared by every deprecated geographic-GeoKey attr.
156+
157+
Centralised so the test suite can match the wording verbatim and so
158+
every emission site uses identical phrasing. See issue #1984 PR 7.
159+
"""
160+
return (
161+
f"xrspatial.geotiff: attrs[{name!r}] is deprecated; the writer "
162+
f"cannot reconstruct it from the canonical CRS so it will not "
163+
f"round-trip. It will be removed in a future release. See "
164+
f"issue #1984."
165+
)
166+
167+
168+
def _emit_deprecated_geographic_geokey(attrs: dict, name: str, value) -> None:
169+
"""Emit a deprecated geographic-GeoKey attr with a DeprecationWarning.
170+
171+
Sets ``attrs[name] = value`` (keeping the read-side emission alive
172+
for one release cycle) after firing a ``DeprecationWarning`` so
173+
callers learn the attr is going away. Used for the six attrs listed
174+
in ``_DEPRECATED_GEOGRAPHIC_GEOKEY_ATTRS``.
175+
"""
176+
warnings.warn(
177+
_deprecated_geographic_geokey_warning(name),
178+
DeprecationWarning,
179+
stacklevel=2,
180+
)
181+
attrs[name] = value
182+
183+
128184
def _extent_to_window(transform, file_height, file_width,
129185
y_min, y_max, x_min, x_max):
130186
"""Convert geographic extent to pixel window (row_start, col_start, row_stop, col_stop).
@@ -240,19 +296,24 @@ def _populate_attrs_from_geo_info(attrs: dict, geo_info, *, window=None) -> None
240296
)
241297

242298
if geo_info.crs_name is not None:
243-
attrs['crs_name'] = geo_info.crs_name
299+
_emit_deprecated_geographic_geokey(attrs, 'crs_name', geo_info.crs_name)
244300
if geo_info.geog_citation is not None:
245-
attrs['geog_citation'] = geo_info.geog_citation
301+
_emit_deprecated_geographic_geokey(
302+
attrs, 'geog_citation', geo_info.geog_citation)
246303
if geo_info.datum_code is not None:
247-
attrs['datum_code'] = geo_info.datum_code
304+
_emit_deprecated_geographic_geokey(
305+
attrs, 'datum_code', geo_info.datum_code)
248306
if geo_info.angular_units is not None:
249-
attrs['angular_units'] = geo_info.angular_units
307+
_emit_deprecated_geographic_geokey(
308+
attrs, 'angular_units', geo_info.angular_units)
250309
if geo_info.linear_units is not None:
251310
attrs['linear_units'] = geo_info.linear_units
252311
if geo_info.semi_major_axis is not None:
253-
attrs['semi_major_axis'] = geo_info.semi_major_axis
312+
_emit_deprecated_geographic_geokey(
313+
attrs, 'semi_major_axis', geo_info.semi_major_axis)
254314
if geo_info.inv_flattening is not None:
255-
attrs['inv_flattening'] = geo_info.inv_flattening
315+
_emit_deprecated_geographic_geokey(
316+
attrs, 'inv_flattening', geo_info.inv_flattening)
256317
if geo_info.projection_code is not None:
257318
attrs['projection_code'] = geo_info.projection_code
258319
if geo_info.vertical_epsg is not None:
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Deprecation-warning tests for the geographic-CRS GeoKey attrs.
2+
3+
Issue #1984, PR 7 of 7.
4+
5+
The six geographic-CRS GeoKey-derived attrs listed below were
6+
documented in the contract as best-effort pass-through, but the
7+
locking test in ``test_attrs_contract_passthrough_1984.py`` (issue
8+
#1984, PR 6, merged as #2004) showed they never round-trip: the
9+
writer's ``build_geo_tags`` only emits the primary
10+
``GEOKEY_GEOGRAPHIC_TYPE`` plus citation, so the secondary GeoKeys
11+
these attrs come from are never written.
12+
13+
PR 7 keeps emitting the attrs on read for one release cycle so
14+
callers can migrate, but each emission now fires a
15+
``DeprecationWarning``. This file pins that warning behaviour:
16+
17+
* One ``test_warns_<attr>`` per attr asserts a ``DeprecationWarning``
18+
with the canonical wording fires when ``_populate_attrs_from_geo_info``
19+
sees the matching ``GeoInfo`` field set.
20+
* ``test_emission_still_present`` asserts the attr value still lands
21+
in ``attrs`` (i.e. PR 7 is warning-only; removal is a later PR).
22+
23+
The test drives ``_populate_attrs_from_geo_info`` directly with a
24+
synthetic :class:`GeoInfo`. That bypasses ``open_geotiff`` and the
25+
writer, both of which are irrelevant here: the contract change is on
26+
the read-side attrs population step, not on the on-disk GeoKey set.
27+
"""
28+
from __future__ import annotations
29+
30+
import warnings
31+
32+
import pytest
33+
34+
from xrspatial.geotiff._attrs import (
35+
_DEPRECATED_GEOGRAPHIC_GEOKEY_ATTRS,
36+
_deprecated_geographic_geokey_warning,
37+
_populate_attrs_from_geo_info,
38+
)
39+
from xrspatial.geotiff._geotags import GeoInfo
40+
41+
42+
# (attr_name, GeoInfo field name, sample value).
43+
#
44+
# Sample values mirror what the GeoTIFF spec would put in each
45+
# secondary GeoKey for WGS 84 (EPSG:4326). The exact values do not
46+
# matter for the warning assertion, but using realistic ones keeps the
47+
# test useful as documentation.
48+
_DEPRECATED_CASES = [
49+
('crs_name', 'crs_name', 'WGS 84'),
50+
('geog_citation', 'geog_citation', 'WGS 84'),
51+
('datum_code', 'datum_code', 6326),
52+
('angular_units', 'angular_units', 'degree'),
53+
('semi_major_axis', 'semi_major_axis', 6378137.0),
54+
('inv_flattening', 'inv_flattening', 298.257223563),
55+
]
56+
57+
58+
def _geo_info_with(**fields) -> GeoInfo:
59+
"""Build a minimal :class:`GeoInfo` with only the given fields set.
60+
61+
A bare ``GeoInfo()`` has every optional field at ``None`` so the
62+
other emission branches in ``_populate_attrs_from_geo_info`` stay
63+
quiet. Only the field under test is populated, which keeps the
64+
warning under test the only ``DeprecationWarning`` raised.
65+
"""
66+
info = GeoInfo()
67+
for name, value in fields.items():
68+
setattr(info, name, value)
69+
return info
70+
71+
72+
def test_deprecated_cases_cover_all_attrs():
73+
"""The parametrised case list must enumerate every attr listed in
74+
``_DEPRECATED_GEOGRAPHIC_GEOKEY_ATTRS``. A drift here would silently
75+
drop coverage for whichever attr was forgotten."""
76+
case_attrs = {c[0] for c in _DEPRECATED_CASES}
77+
assert case_attrs == set(_DEPRECATED_GEOGRAPHIC_GEOKEY_ATTRS), (
78+
f"deprecated cases drift from the module-level tuple:\n"
79+
f" only in cases : {sorted(case_attrs - set(_DEPRECATED_GEOGRAPHIC_GEOKEY_ATTRS))}\n"
80+
f" only in module: {sorted(set(_DEPRECATED_GEOGRAPHIC_GEOKEY_ATTRS) - case_attrs)}"
81+
)
82+
83+
84+
@pytest.mark.parametrize(
85+
'attr,field,value',
86+
_DEPRECATED_CASES,
87+
ids=[c[0] for c in _DEPRECATED_CASES],
88+
)
89+
def test_warns_on_emission(attr, field, value):
90+
"""Each deprecated geographic-GeoKey attr fires a DeprecationWarning
91+
with the canonical wording when ``_populate_attrs_from_geo_info``
92+
emits it."""
93+
info = _geo_info_with(**{field: value})
94+
95+
with warnings.catch_warnings(record=True) as caught:
96+
warnings.simplefilter('always')
97+
attrs: dict = {}
98+
_populate_attrs_from_geo_info(attrs, info)
99+
100+
matching = [
101+
w for w in caught
102+
if issubclass(w.category, DeprecationWarning)
103+
and _deprecated_geographic_geokey_warning(attr) == str(w.message)
104+
]
105+
assert len(matching) == 1, (
106+
f"expected exactly one DeprecationWarning for {attr!r}; got "
107+
f"{[(w.category.__name__, str(w.message)) for w in caught]}"
108+
)
109+
110+
111+
@pytest.mark.parametrize(
112+
'attr,field,value',
113+
_DEPRECATED_CASES,
114+
ids=[c[0] for c in _DEPRECATED_CASES],
115+
)
116+
def test_emission_still_present(attr, field, value):
117+
"""Deprecation-period contract: the attr value still lands in attrs.
118+
119+
Removal is a later PR. If a reader change drops the emission
120+
entirely while this test still expects presence, the failure here
121+
is the signal to bump the contract version and move the attr from
122+
the deprecated tier to the removed tier in the docstring."""
123+
info = _geo_info_with(**{field: value})
124+
125+
with warnings.catch_warnings():
126+
warnings.simplefilter('ignore', DeprecationWarning)
127+
attrs: dict = {}
128+
_populate_attrs_from_geo_info(attrs, info)
129+
130+
assert attr in attrs, (
131+
f"deprecated attr {attr!r} was dropped during PR 7's warning-only "
132+
f"phase. PR 7 keeps emitting; removal is scheduled for a later "
133+
f"release. attrs keys present: {sorted(attrs.keys())}"
134+
)
135+
assert attrs[attr] == value
136+
137+
138+
def test_no_warning_when_field_absent():
139+
"""A GeoInfo with none of the deprecated fields set fires no
140+
DeprecationWarning. Guards against an unconditional warning that
141+
would spam every read of every TIFF."""
142+
info = GeoInfo()
143+
144+
with warnings.catch_warnings(record=True) as caught:
145+
warnings.simplefilter('always')
146+
attrs: dict = {}
147+
_populate_attrs_from_geo_info(attrs, info)
148+
149+
dep = [w for w in caught if issubclass(w.category, DeprecationWarning)]
150+
assert dep == [], (
151+
f"DeprecationWarning fired even though no deprecated field was "
152+
f"set on the GeoInfo: {[str(w.message) for w in dep]}"
153+
)
154+
155+
156+
def test_warning_message_format():
157+
"""Sanity-check the warning text shape so the canonical wording
158+
stays stable across the deprecation cycle."""
159+
msg = _deprecated_geographic_geokey_warning('crs_name')
160+
assert "xrspatial.geotiff" in msg
161+
assert "attrs['crs_name']" in msg
162+
assert "deprecated" in msg
163+
assert "round-trip" in msg
164+
assert "#1984" in msg

0 commit comments

Comments
 (0)