Coverage for tests/test_schema_versioning.py: 98%

151 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-02 05:35 -0400

1# This file is part of lsst-images. 

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# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11 

12from __future__ import annotations 

13 

14import json as json_module 

15import warnings 

16from pathlib import Path 

17from typing import Any, ClassVar 

18 

19import pydantic 

20import pytest 

21 

22from lsst.images import ImageSerializationModel 

23from lsst.images.json._output_archive import JsonOutputArchive 

24from lsst.images.serialization import ( 

25 ArchiveReadError, 

26 ArchiveTree, 

27 DevelopmentSchemaWarning, 

28 InputArchive, 

29 is_development_version, 

30 warn_for_development_schemas, 

31) 

32from lsst.images.serialization._common import ( 

33 _ARCHIVE_READ_CONTEXT, 

34 _check_compat, 

35 _check_format_version, 

36) 

37from lsst.images.tests import ( 

38 check_archive_tree_class_invariants, 

39 current_fixture_path, 

40 iter_concrete_archive_tree_subclasses, 

41) 

42 

43FIXTURE_DIR = Path(__file__).parent / "data" / "schemas" 

44 

45 

46class _DummyArchiveTree(ArchiveTree): 

47 """Minimal concrete ArchiveTree for testing the base-class machinery.""" 

48 

49 SCHEMA_NAME: ClassVar[str] = "dummy" 

50 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

51 MIN_READ_VERSION: ClassVar[int] = 1 

52 PUBLIC_TYPE: ClassVar[type] = object 

53 

54 def deserialize( 

55 self, archive: InputArchive[Any], **kwargs: Any 

56 ) -> Any: # pragma: no cover - never invoked 

57 raise NotImplementedError() 

58 

59 

60class _DevTree(ArchiveTree): 

61 """Development-version tree double.""" 

62 

63 SCHEMA_NAME = "in_dev_helper_test_dev" 

64 SCHEMA_VERSION = "1.0.0.dev0" 

65 MIN_READ_VERSION = 1 

66 PUBLIC_TYPE = object 

67 

68 def deserialize(self, archive, **kwargs): # pragma: no cover - never invoked 

69 raise NotImplementedError() 

70 

71 

72class _FinalTree(ArchiveTree): 

73 """Finalized-version tree double.""" 

74 

75 SCHEMA_NAME = "in_dev_helper_test_final" 

76 SCHEMA_VERSION = "1.0.0" 

77 MIN_READ_VERSION = 1 

78 PUBLIC_TYPE = object 

79 

80 def deserialize(self, archive, **kwargs): # pragma: no cover - never invoked 

81 raise NotImplementedError() 

82 

83 

84def test_is_development_version() -> None: 

85 """Verify is_development_version detects development versions correctly.""" 

86 assert is_development_version("1.0.0.dev0") 

87 assert is_development_version("2.3.4.dev12") 

88 assert not is_development_version("1.0.0") 

89 assert not is_development_version("2.3.4") 

90 

91 

92def test_warn_for_development_schemas_warns() -> None: 

93 """Verify warn_for_development_schemas warns for development schemas.""" 

94 with pytest.warns(DevelopmentSchemaWarning, match="in_dev_helper_test_dev"): 

95 warn_for_development_schemas(_DevTree()) 

96 

97 

98def test_warn_for_development_schemas_silent_when_finalized() -> None: 

99 """Verify warn_for_development_schemas is silent for finalized schemas.""" 

100 with warnings.catch_warnings(): 

101 warnings.simplefilter("error") 

102 warn_for_development_schemas(_FinalTree()) 

103 

104 

105def test_silent_when_min_read_satisfied() -> None: 

106 """Verify no error when min_read_version equals reader major.""" 

107 _check_compat("foo", "1.0.0", 1, "1.0.0") 

108 

109 

110def test_silent_when_on_disk_major_is_lower() -> None: 

111 """Verify no error when a 1.0.0 file is read by 2.0.0 code.""" 

112 _check_compat("foo", "1.0.0", 1, "2.0.0") 

113 

114 

115def test_silent_when_on_disk_major_is_higher_but_min_read_low() -> None: 

116 """Verify no error when a 2.0.0 file is safe for major-1 readers.""" 

117 _check_compat("foo", "2.0.0", 1, "1.0.0") 

118 

119 

120def test_raises_when_min_read_exceeds_reader_major() -> None: 

121 """Verify ArchiveReadError is raised when min_read_version exceeds 

122 major. 

123 """ 

124 with pytest.raises(ArchiveReadError) as exc_info: 

125 _check_compat("foo", "2.0.0", 2, "1.0.0") 

126 assert "foo" in str(exc_info.value) 

127 assert ">= 2" in str(exc_info.value) 

128 

129 

130def test_format_version_silent_when_equal() -> None: 

131 """Verify no error when on-disk format version equals reader version.""" 

132 _check_format_version("fits", 1, 1) 

133 

134 

135def test_format_version_silent_when_on_disk_lower() -> None: 

136 """Verify no error when on-disk format version is lower than reader's.""" 

137 _check_format_version("fits", 1, 2) 

138 

139 

140def test_format_version_raises_when_on_disk_higher() -> None: 

141 """Verify ArchiveReadError when on-disk format version is too new.""" 

142 with pytest.raises(ArchiveReadError): 

143 _check_format_version("fits", 2, 1) 

144 

145 

146def test_default_values_filled_from_classvars() -> None: 

147 """Verify default instance values are filled from class-var constants.""" 

148 instance = _DummyArchiveTree() 

149 assert instance.schema_version == "1.0.0" 

150 assert instance.min_read_version == 1 

151 

152 

153def test_schema_url_is_computed() -> None: 

154 """Verify schema_url is computed from SCHEMA_NAME and SCHEMA_VERSION.""" 

155 instance = _DummyArchiveTree() 

156 assert instance.schema_url == "https://images.lsst.io/schemas/dummy-1.0.0" 

157 

158 

159def test_schema_url_appears_in_dump() -> None: 

160 """Verify schema_url, schema_version, and min_read_version are in dumps.""" 

161 instance = _DummyArchiveTree() 

162 dumped = instance.model_dump() 

163 assert dumped["schema_url"] == "https://images.lsst.io/schemas/dummy-1.0.0" 

164 assert dumped["schema_version"] == "1.0.0" 

165 assert dumped["min_read_version"] == 1 

166 

167 

168def test_schema_url_ignored_in_input() -> None: 

169 """Verify schema_url in input data is ignored; computed value is used.""" 

170 # Pydantic's default extra='ignore' drops it from inputs. 

171 instance = _DummyArchiveTree.model_validate( 

172 {"schema_url": "https://example.com/wrong", "schema_version": "1.0.0", "min_read_version": 1} 

173 ) 

174 assert instance.schema_url == "https://images.lsst.io/schemas/dummy-1.0.0" 

175 

176 

177def test_normalises_to_in_code_values() -> None: 

178 """Verify an older file's schema values are normalised to in-code 

179 values. 

180 """ 

181 # An older file's values are normalised on load. 

182 instance = _DummyArchiveTree.model_validate({"schema_version": "0.9.0", "min_read_version": 1}) 

183 assert instance.schema_version == "1.0.0" 

184 assert instance.min_read_version == 1 

185 

186 

187def test_absent_fields_default_for_fresh_construction() -> None: 

188 """Verify fresh in-memory construction may omit version fields.""" 

189 instance = _DummyArchiveTree.model_validate({}) 

190 assert instance.schema_version == "1.0.0" 

191 assert instance.min_read_version == 1 

192 

193 

194def test_archive_tree_requires_an_explicit_schema_version() -> None: 

195 """Verify archive input cannot silently assume a version.""" 

196 with pytest.raises(pydantic.ValidationError, match="has no schema_version"): 

197 _DummyArchiveTree.model_validate({}, context=_ARCHIVE_READ_CONTEXT) 

198 

199 

200def test_archive_context_requires_versions_on_nested_trees() -> None: 

201 """Verify the archive-read marker propagates into embedded schemas.""" 

202 

203 class _ParentTree(ArchiveTree): 

204 SCHEMA_NAME: ClassVar[str] = "version_context_parent_test" 

205 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

206 MIN_READ_VERSION: ClassVar[int] = 1 

207 PUBLIC_TYPE: ClassVar[type] = object 

208 

209 child: _DummyArchiveTree 

210 

211 def deserialize(self, archive: InputArchive[Any], **kwargs: Any) -> Any: 

212 raise NotImplementedError() 

213 

214 with pytest.raises(pydantic.ValidationError, match="dummy: archive tree has no schema_version"): 

215 _ParentTree.model_validate( 

216 {"schema_version": "1.0.0", "min_read_version": 1, "child": {}}, 

217 context=_ARCHIVE_READ_CONTEXT, 

218 ) 

219 

220 

221def test_min_read_version_too_high_rejected() -> None: 

222 """Verify min_read_version higher than the reader major is rejected.""" 

223 # Pydantic mode='after' re-raises ArchiveReadError without 

224 # wrapping it in ValidationError. 

225 with pytest.raises((ArchiveReadError, pydantic.ValidationError)): 

226 _DummyArchiveTree.model_validate({"schema_version": "2.0.0", "min_read_version": 2}) 

227 

228 

229def test_image_schema_has_id_and_title() -> None: 

230 """Verify Image's serialization-mode schema has ``$id`` / ``title`` set.""" 

231 schema = ImageSerializationModel.model_json_schema(mode="serialization") 

232 assert schema["$id"] == "https://images.lsst.io/schemas/image-1.0.0" 

233 assert schema["title"] == "image" 

234 

235 

236def test_constants_are_declared() -> None: 

237 """Verify all three ClassVars are declared and well-formed everywhere.""" 

238 found = list(iter_concrete_archive_tree_subclasses()) 

239 assert len(found) > 0 

240 for sub in found: 

241 check_archive_tree_class_invariants(sub) 

242 

243 

244def test_schema_names_unique() -> None: 

245 """Verify all SCHEMA_NAME values across concrete subclasses are unique.""" 

246 names: dict[str, type] = {} 

247 for sub in iter_concrete_archive_tree_subclasses(): 

248 # Skip our local _DummyArchiveTree (it lives in a test module). 

249 if sub.__module__.startswith("tests."): 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true

250 continue 

251 # Skip Pydantic generic parametrisations (e.g. 

252 # MaskedImageSerializationModel[TypeVar]); only the original 

253 # generic class counts. A parametrised form has a non-empty 

254 # __pydantic_generic_metadata__["args"]. 

255 generic_meta = getattr(sub, "__pydantic_generic_metadata__", {}) 

256 if generic_meta.get("args"): 

257 continue 

258 existing = names.get(sub.SCHEMA_NAME) 

259 if existing is not None: 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true

260 pytest.fail( 

261 f"Duplicate SCHEMA_NAME {sub.SCHEMA_NAME!r}: {sub.__qualname__} vs {existing.__qualname__}" 

262 ) 

263 names[sub.SCHEMA_NAME] = sub 

264 

265 

266@pytest.fixture 

267def fixture_path() -> Path: 

268 """Return the path to the committed image schema fixture.""" 

269 path = current_fixture_path(FIXTURE_DIR, "image") 

270 assert path.exists() 

271 return path 

272 

273 

274def test_min_read_too_high_raises(fixture_path: Path) -> None: 

275 """Verify setting min_read_version above reader major rejects the read.""" 

276 tree = json_module.loads(fixture_path.read_text()) 

277 tree["min_read_version"] = 99 

278 with pytest.raises((ArchiveReadError, pydantic.ValidationError)): 

279 ImageSerializationModel.model_validate(tree) 

280 

281 

282def test_higher_major_with_low_min_read_succeeds(fixture_path: Path) -> None: 

283 """Verify a high schema_version with low min_read_version reads 

284 silently. 

285 """ 

286 tree = json_module.loads(fixture_path.read_text()) 

287 tree["schema_version"] = "99.0.0" 

288 tree["min_read_version"] = 1 

289 # Asymmetric escape: a 99.0.0 file that declares it's safe for 

290 # major-1 readers reads silently. 

291 instance = ImageSerializationModel.model_validate(tree) 

292 # And gets normalised back to in-code values. 

293 assert instance.schema_version == "1.0.0" 

294 assert instance.min_read_version == 1 

295 

296 

297class _WritableDevObject: 

298 _archive_default_name = None 

299 

300 def serialize(self, archive): 

301 return _DevTree() 

302 

303 

304class _WritableFinalObject: 

305 _archive_default_name = None 

306 

307 def serialize(self, archive): 

308 return _FinalTree() 

309 

310 

311def test_serialize_root_warns_for_development() -> None: 

312 """Verify serialize_root warns when the tree uses a development schema.""" 

313 with pytest.warns(DevelopmentSchemaWarning, match="in_dev_helper_test_dev"): 

314 JsonOutputArchive().serialize_root(_WritableDevObject()) 

315 

316 

317def test_serialize_root_silent_for_finalized() -> None: 

318 """Verify serialize_root does not warn for a finalized schema.""" 

319 with warnings.catch_warnings(): 

320 warnings.simplefilter("error") 

321 JsonOutputArchive().serialize_root(_WritableFinalObject())