Coverage for tests/test_io_model_data.py: 96%

70 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-25 01:27 -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/>. 

21 

22"""Tests for the LsstScarletModelData schema migrations.""" 

23 

24import copy 

25import importlib 

26import unittest 

27from unittest import mock 

28 

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) 

40 

41 

42class TestModelDataMigrations(lsst.utils.tests.TestCase): 

43 """Tests for the migration chain and schema constants in 

44 ``lsst.meas.extensions.scarlet.io.model_data``. 

45 

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 """ 

51 

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) 

64 

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}) 

80 

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 ) 

101 

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. 

105 

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}) 

127 

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. 

131 

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 ) 

149 

150 

151class TestScarletLiteSchemaCheck(lsst.utils.tests.TestCase): 

152 """Tests for the scarlet_lite schema-drift safety net. 

153 

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. 

165 

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 """ 

171 

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") 

177 

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)) 

185 

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)) 

191 

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)) 

197 

198 def test_drift_scarlet_older(self): 

199 """Installed scarlet behind the pinned version → RuntimeError. 

200 

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") 

213 

214 def test_check_wired_at_import(self): 

215 """The check fires at module import on a real version drift. 

216 

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) 

239 

240 

241def setup_module(module): 

242 lsst.utils.tests.init() 

243 

244 

245class MemoryTester(lsst.utils.tests.MemoryTestCase): 

246 pass 

247 

248 

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()