Coverage for tests/test_schema_versioning.py: 97%

107 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-16 15:24 -0700

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 

15from pathlib import Path 

16from typing import Any, ClassVar 

17 

18import pydantic 

19import pytest 

20 

21from lsst.images import ImageSerializationModel 

22from lsst.images.serialization import ArchiveReadError, ArchiveTree, InputArchive 

23from lsst.images.serialization._common import _check_compat, _check_format_version 

24from lsst.images.tests import check_archive_tree_class_invariants, iter_concrete_archive_tree_subclasses 

25 

26SCHEMA_DIR = Path(__file__).parent / "data" / "schema_v1" 

27 

28 

29class _DummyArchiveTree(ArchiveTree): 

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

31 

32 SCHEMA_NAME: ClassVar[str] = "dummy" 

33 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

34 MIN_READ_VERSION: ClassVar[int] = 1 

35 PUBLIC_TYPE: ClassVar[type] = object 

36 

37 def deserialize( 

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

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

40 raise NotImplementedError() 

41 

42 

43def test_silent_when_min_read_satisfied() -> None: 

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

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

46 

47 

48def test_silent_when_on_disk_major_is_lower() -> None: 

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

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

51 

52 

53def test_silent_when_on_disk_major_is_higher_but_min_read_low() -> None: 

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

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

56 

57 

58def test_raises_when_min_read_exceeds_reader_major() -> None: 

59 """Verify ArchiveReadError is raised when min_read_version exceeds 

60 major. 

61 """ 

62 with pytest.raises(ArchiveReadError) as exc_info: 

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

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

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

66 

67 

68def test_format_version_silent_when_equal() -> None: 

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

70 _check_format_version("fits", 1, 1) 

71 

72 

73def test_format_version_silent_when_on_disk_lower() -> None: 

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

75 _check_format_version("fits", 1, 2) 

76 

77 

78def test_format_version_raises_when_on_disk_higher() -> None: 

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

80 with pytest.raises(ArchiveReadError): 

81 _check_format_version("fits", 2, 1) 

82 

83 

84def test_default_values_filled_from_classvars() -> None: 

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

86 instance = _DummyArchiveTree() 

87 assert instance.schema_version == "1.0.0" 

88 assert instance.min_read_version == 1 

89 

90 

91def test_schema_url_is_computed() -> None: 

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

93 instance = _DummyArchiveTree() 

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

95 

96 

97def test_schema_url_appears_in_dump() -> None: 

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

99 instance = _DummyArchiveTree() 

100 dumped = instance.model_dump() 

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

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

103 assert dumped["min_read_version"] == 1 

104 

105 

106def test_schema_url_ignored_in_input() -> None: 

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

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

109 instance = _DummyArchiveTree.model_validate( 

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

111 ) 

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

113 

114 

115def test_normalises_to_in_code_values() -> None: 

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

117 values. 

118 """ 

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

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

121 assert instance.schema_version == "1.0.0" 

122 assert instance.min_read_version == 1 

123 

124 

125def test_absent_fields_default_to_legacy() -> None: 

126 """Verify absent version fields default to the legacy v1 values.""" 

127 instance = _DummyArchiveTree.model_validate({}) 

128 assert instance.schema_version == "1.0.0" 

129 assert instance.min_read_version == 1 

130 

131 

132def test_min_read_version_too_high_rejected() -> None: 

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

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

135 # wrapping it in ValidationError. 

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

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

138 

139 

140def test_image_schema_has_id_and_title() -> None: 

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

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

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

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

145 

146 

147def test_constants_are_declared() -> None: 

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

149 found = list(iter_concrete_archive_tree_subclasses()) 

150 assert len(found) > 0 

151 for sub in found: 

152 check_archive_tree_class_invariants(sub) 

153 

154 

155def test_schema_names_unique() -> None: 

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

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

158 for sub in iter_concrete_archive_tree_subclasses(): 

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

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

161 continue 

162 # Skip Pydantic generic parametrisations (e.g. 

163 # MaskedImageSerializationModel[TypeVar]); only the original 

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

165 # __pydantic_generic_metadata__["args"]. 

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

167 if generic_meta.get("args"): 

168 continue 

169 existing = names.get(sub.SCHEMA_NAME) 

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

171 pytest.fail( 

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

173 ) 

174 names[sub.SCHEMA_NAME] = sub 

175 

176 

177@pytest.fixture 

178def fixture_path() -> Path: 

179 """Return the path to the committed image.json schema fixture.""" 

180 path = SCHEMA_DIR / "image.json" 

181 assert path.exists() 

182 return path 

183 

184 

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

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

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

188 tree["min_read_version"] = 99 

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

190 ImageSerializationModel.model_validate(tree) 

191 

192 

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

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

195 silently. 

196 """ 

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

198 tree["schema_version"] = "99.0.0" 

199 tree["min_read_version"] = 1 

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

201 # major-1 readers reads silently. 

202 instance = ImageSerializationModel.model_validate(tree) 

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

204 assert instance.schema_version == "1.0.0" 

205 assert instance.min_read_version == 1 

206 

207 

208def test_absent_fields_default_to_legacy_fixture(fixture_path: Path) -> None: 

209 """Verify stripping the version fields entirely reads with v1 defaults.""" 

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

211 del tree["schema_version"] 

212 del tree["min_read_version"] 

213 del tree["schema_url"] 

214 instance = ImageSerializationModel.model_validate(tree) 

215 assert instance.schema_version == "1.0.0" 

216 assert instance.min_read_version == 1