Coverage for tests/test_io_model_data.py: 96%
70 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-21 02:07 -0700
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-21 02:07 -0700
1# This file is part of meas_extensions_scarlet.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22"""Tests for the LsstScarletModelData schema migrations."""
24import copy
25import importlib
26import unittest
27from unittest import mock
29import lsst.scarlet.lite as scl
30import lsst.utils.tests
31from lsst.meas.extensions.scarlet.io import model_data as model_data_module
32from lsst.meas.extensions.scarlet.io.model_data import (
33 CURRENT_SCHEMA,
34 MODEL_TYPE,
35 SCARLET_LITE_SCHEMA,
36 _checkScarletLiteSchema,
37 _to_1_0_0,
38 _to_1_0_1,
39)
42class TestModelDataMigrations(lsst.utils.tests.TestCase):
43 """Tests for the migration chain and schema constants in
44 ``lsst.meas.extensions.scarlet.io.model_data``.
46 Each migration function bumps the version and adds the keys
47 introduced at that schema step. A regression in any of them
48 silently corrupts deblend catalogs read from disk that were
49 written by an earlier release.
50 """
52 def test_to_1_0_0_adds_isolated_key(self):
53 """``_to_1_0_0`` adds ``isolated={}``, the ``model_type`` tag,
54 and the schema version to pre-schema data.
55 """
56 # Pre-schema data carries only the ``blends`` key inherited
57 # from scarlet_lite's ScarletModelData; no model_type,
58 # isolated, or version was emitted before 1.0.0.
59 pre = {"blends": {}}
60 result = _to_1_0_0(copy.deepcopy(pre))
61 self.assertEqual(result["isolated"], {})
62 self.assertEqual(result["version"], "1.0.0")
63 self.assertEqual(result["model_type"], MODEL_TYPE)
65 def test_to_1_0_1_adds_footprint_metadata(self):
66 """``_to_1_0_1`` adds ``metadata={"footprint": None}`` and the
67 schema version to 1.0.0 data that had no metadata at all.
68 """
69 # 1.0.0 data has isolated, model_type, version — but no
70 # metadata key; 1.0.1 introduced footprint metadata.
71 v1_0_0 = {
72 "blends": {},
73 "isolated": {},
74 "model_type": MODEL_TYPE,
75 "version": "1.0.0",
76 }
77 result = _to_1_0_1(copy.deepcopy(v1_0_0))
78 self.assertEqual(result["version"], "1.0.1")
79 self.assertEqual(result["metadata"], {"footprint": None})
81 def test_to_1_0_1_preserves_existing_metadata(self):
82 """When 1.0.0 data already carries a ``metadata`` dict (without
83 a ``footprint`` key), ``_to_1_0_1`` adds ``footprint=None``
84 and leaves the other keys intact.
85 """
86 # Pins the ``setdefault(...).setdefault(...)`` contract — a
87 # naive ``data["metadata"] = {"footprint": None}`` rewrite
88 # would silently drop pre-existing metadata.
89 v1_0_0 = {
90 "blends": {},
91 "isolated": {},
92 "model_type": MODEL_TYPE,
93 "version": "1.0.0",
94 "metadata": {"survey": "DES"},
95 }
96 result = _to_1_0_1(copy.deepcopy(v1_0_0))
97 self.assertEqual(result["version"], "1.0.1")
98 self.assertEqual(
99 result["metadata"], {"survey": "DES", "footprint": None}
100 )
102 def test_to_1_0_1_handles_none_metadata(self):
103 """``_to_1_0_1`` tolerates an explicit ``metadata=None`` entry
104 from older payloads.
106 Regression test for finding IO-3 of
107 ``audits/audit-2026-05-05.md``. The naive
108 ``data.setdefault("metadata", {}).setdefault("footprint", None)``
109 idiom returned the existing ``None`` and then raised
110 ``AttributeError: 'NoneType' object has no attribute 'setdefault'``
111 when the payload carried ``metadata=None`` explicitly — the
112 same value emitted by
113 ``scarlet_model_to_lsst_scarlet_model`` for converted v0
114 archives. The migration must replace ``None`` with
115 ``{"footprint": None}``.
116 """
117 v1_0_0 = {
118 "blends": {},
119 "isolated": {},
120 "model_type": MODEL_TYPE,
121 "version": "1.0.0",
122 "metadata": None,
123 }
124 result = _to_1_0_1(copy.deepcopy(v1_0_0))
125 self.assertEqual(result["version"], "1.0.1")
126 self.assertEqual(result["metadata"], {"footprint": None})
128 def test_schema_version_constants_match(self):
129 """The schema constants line up with what's actually registered
130 and with the scarlet_lite version installed.
132 - ``SCARLET_LITE_SCHEMA`` is the scarlet_lite schema this
133 package was last verified against; it must match
134 ``scl.io.model_data.CURRENT_SCHEMA`` (the version of
135 scarlet_lite actually installed). A drift here is what
136 the module's import-time check raises on.
137 - ``CURRENT_SCHEMA`` is the current
138 ``meas_extensions_scarlet`` model schema; it must match
139 what's recorded as current for ``MODEL_TYPE`` in the
140 migration registry.
141 """
142 self.assertEqual(
143 SCARLET_LITE_SCHEMA, scl.io.model_data.CURRENT_SCHEMA
144 )
145 self.assertEqual(
146 CURRENT_SCHEMA,
147 scl.io.migration.MigrationRegistry.current[MODEL_TYPE],
148 )
151class TestScarletLiteSchemaCheck(lsst.utils.tests.TestCase):
152 """Tests for the scarlet_lite schema-drift safety net.
154 Covers finding C-7 of the ``audits/audit-2026-05-05.md`` audit:
155 a stray trailing comma packed the version-comparison operands
156 into a tuple of lists, so the very mechanism designed to detect
157 schema drift raised ``TypeError`` instead of the intended
158 ``RuntimeError`` the first time
159 ``scl.io.model_data.CURRENT_SCHEMA`` ever differed from
160 ``SCARLET_LITE_SCHEMA``. The same block also compared the wrong
161 pair of versions (the meas_extensions schema against the pinned
162 scarlet schema, instead of the installed scarlet schema against
163 the pinned one), so even with the comma dropped the check did
164 not match what its error message claimed.
166 The fixed helper is a bidirectional drift guard: any mismatch
167 between installed and pinned schema strings fires, because an
168 older installed scarlet may not emit the keys this package
169 expects and a newer one may have changed them.
170 """
172 def test_matching_versions(self):
173 """Equal scarlet and pinned schemas → no raise."""
174 # Sanity check: the no-drift case must stay silent.
175 _checkScarletLiteSchema("1.0.0", "1.0.0")
176 _checkScarletLiteSchema("2.5.7", "2.5.7")
178 def test_drift_scarlet_newer_major(self):
179 """Installed scarlet ahead by a major version → RuntimeError."""
180 with self.assertRaises(RuntimeError) as cm:
181 _checkScarletLiteSchema("2.0.0", "1.5.9")
182 # Message names the installed scarlet version so the
183 # developer knows which schema to migrate to.
184 self.assertIn("2.0.0", str(cm.exception))
186 def test_drift_scarlet_newer_minor(self):
187 """Installed scarlet ahead by a minor version → RuntimeError."""
188 with self.assertRaises(RuntimeError) as cm:
189 _checkScarletLiteSchema("1.1.0", "1.0.5")
190 self.assertIn("1.1.0", str(cm.exception))
192 def test_drift_scarlet_newer_patch(self):
193 """Installed scarlet ahead by a patch version → RuntimeError."""
194 with self.assertRaises(RuntimeError) as cm:
195 _checkScarletLiteSchema("1.0.1", "1.0.0")
196 self.assertIn("1.0.1", str(cm.exception))
198 def test_drift_scarlet_older(self):
199 """Installed scarlet behind the pinned version → RuntimeError.
201 Pins the bidirectional semantics: an older installed
202 scarlet is just as much of a drift as a newer one, because
203 the keys this package's IO layer expects to read or write
204 may not exist yet in the older schema.
205 """
206 with self.assertRaises(RuntimeError) as cm:
207 _checkScarletLiteSchema("1.0.0", "1.0.1")
208 self.assertIn("1.0.0", str(cm.exception))
209 with self.assertRaises(RuntimeError):
210 _checkScarletLiteSchema("1.0.0", "1.1.0")
211 with self.assertRaises(RuntimeError):
212 _checkScarletLiteSchema("0.9.9", "1.0.0")
214 def test_check_wired_at_import(self):
215 """The check fires at module import on a real version drift.
217 Reproduces the dormant failure path of finding C-7 from the
218 ``audits/audit-2026-05-05.md`` audit. Patching
219 ``scl.io.model_data.CURRENT_SCHEMA`` to a newer value and
220 reloading the module re-runs the import-time check; the
221 original bug raised ``TypeError`` from the malformed
222 ``int(list)``, while the fix raises the actionable
223 ``RuntimeError`` that names the new scarlet version.
224 """
225 # Patch the installed-scarlet version to something newer
226 # than SCARLET_LITE_SCHEMA so the drift branch fires.
227 with mock.patch.object(
228 scl.io.model_data, "CURRENT_SCHEMA", "9.9.9"
229 ):
230 with self.assertRaises(RuntimeError) as cm:
231 importlib.reload(model_data_module)
232 self.assertIn("9.9.9", str(cm.exception))
233 # Restore the module to its real state for the rest of the
234 # test session — the reload above ran against the patched
235 # value but the module is now imported with the wrong (now-
236 # unpatched) state. Reloading once more rebinds everything
237 # to the genuine constants.
238 importlib.reload(model_data_module)
241def setup_module(module):
242 lsst.utils.tests.init()
245class MemoryTester(lsst.utils.tests.MemoryTestCase):
246 pass
249if __name__ == "__main__": 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true
250 lsst.utils.tests.init()
251 unittest.main()