Coverage for tests/test_schema_migration.py: 98%

89 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-09-29 10:33 +0000

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"""The schema migration chain. 

12 

13Migration is proven by purpose-built schemas from ``schema_doubles``, which is 

14never frozen, published, or part of shipped data. 

15""" 

16 

17from __future__ import annotations 

18 

19from pathlib import Path 

20from typing import Any, ClassVar 

21 

22import pydantic 

23import pytest 

24from schema_doubles import ( 

25 ChainTestModel, 

26 GapTestModel, 

27 MigrationIsolationTestModel, 

28 MigrationTestModel, 

29 ProjectionTestModel, 

30 UnmigratableTestModel, 

31) 

32 

33from lsst.images.serialization import ArchiveReadError, ArchiveTree, InputArchive, migration 

34from lsst.images.serialization._migrations import _MIGRATIONS 

35from lsst.images.tests import iter_schema_fixtures, read_fixture_tree 

36 

37DOUBLE_DIR = Path(__file__).parent / "data" / "fixture_doubles" 

38 

39 

40def test_registration() -> None: 

41 """Verify the decorator registers one entry per (name, from_major).""" 

42 assert ("migration_test", 1) in _MIGRATIONS 

43 assert ("migration_isolation_test", 1) in _MIGRATIONS 

44 assert ("chain_test", 1) in _MIGRATIONS 

45 assert ("chain_test", 2) in _MIGRATIONS 

46 assert ("gap_test", 2) in _MIGRATIONS 

47 

48 

49def test_registering_twice_raises() -> None: 

50 """Verify a duplicate registration is refused rather than silently won.""" 

51 with pytest.raises(RuntimeError, match="already registered"): 

52 

53 @migration("migration_test", 1) 

54 def _duplicate(data: dict[str, Any]) -> dict[str, Any]: 

55 return data 

56 

57 

58def test_migration_morphs_an_old_tree() -> None: 

59 """Verify a 1.0.0 tree reads through the chain into the 2.0.0 shape.""" 

60 tree = MigrationTestModel.model_validate( 

61 {"schema_version": "1.0.0", "min_read_version": 1, "original": "hello"} 

62 ) 

63 assert tree.renamed == "hello" 

64 assert tree.schema_version == "2.0.0" 

65 assert tree.min_read_version == 2 

66 

67 

68def test_migration_leaves_a_current_tree_alone() -> None: 

69 """Verify a current-version tree is not passed through any step.""" 

70 tree = MigrationTestModel.model_validate( 

71 {"schema_version": "2.0.0", "min_read_version": 2, "renamed": "hello"} 

72 ) 

73 assert tree.renamed == "hello" 

74 

75 

76def test_migration_chains_across_two_majors() -> None: 

77 """Verify a 1.0.0 tree runs both registered steps, in order.""" 

78 tree = ChainTestModel.model_validate({"schema_version": "1.0.0", "min_read_version": 1}) 

79 assert tree.steps == ["1->2", "2->3"] 

80 

81 

82def test_failed_union_migration_does_not_mutate_the_fallback_input() -> None: 

83 """Verify a failed migration candidate is isolated from later variants. 

84 

85 ``MigrationIsolationTestModel`` and its migration live in 

86 ``schema_doubles`` rather than here: registration is process-global and 

87 refuses to be replaced, so registering from inside a test body would make 

88 the test fail on any second run in the same process. 

89 """ 

90 

91 class _Fallback(pydantic.BaseModel): 

92 schema_version: str 

93 min_read_version: int 

94 steps: list[str] 

95 

96 class _Wrapper(pydantic.BaseModel): 

97 member: MigrationIsolationTestModel | _Fallback = pydantic.Field(union_mode="left_to_right") 

98 

99 payload = {"member": {"schema_version": "1.0.0", "min_read_version": 1, "steps": ["original"]}} 

100 wrapper = _Wrapper.model_validate(payload) 

101 assert isinstance(wrapper.member, _Fallback) 

102 assert wrapper.member.steps == ["original"] 

103 assert payload["member"]["steps"] == ["original"] 

104 

105 

106def test_missing_step_raises_a_clean_error() -> None: 

107 """Verify a genuine migration gap names the missing step, not a stray 

108 error. 

109 

110 GapTestModel registers only its 2->3 step, so a tree stamped at major 1 

111 has nothing registered for 1->2 -- a real gap, unlike a schema that has 

112 registered no migration at all (see the fallthrough tests below). 

113 

114 The gap is raised from a ``mode="before"`` validator as an exception that 

115 is deliberately both `ArchiveReadError` and `ValueError` (see 

116 ``_MigrationGapError``), so a ``left_to_right`` union can treat it as "try 

117 the next variant" (`test_migration_gap_falls_through_a_left_to_right_union` 

118 below). That same dual nature means Pydantic -- which wraps any 

119 validator-raised `ValueError` into its own `pydantic.ValidationError`, 

120 indistinguishably from a nested union trial or a standalone top-level 

121 call -- reports this one as a `pydantic.ValidationError` here rather than 

122 a bare `ArchiveReadError`, exactly as it already does for other schema 

123 incompatibilities (see ``test_retire_double_fixture_is_rejected``); the 

124 message naming the missing step survives that wrapping unchanged. 

125 """ 

126 with pytest.raises((ArchiveReadError, pydantic.ValidationError), match="no migration from major 1 to 2"): 

127 GapTestModel.model_validate({"schema_version": "1.0.0", "min_read_version": 1}) 

128 

129 

130def test_schemas_without_migrations_are_untouched() -> None: 

131 """Verify the fast path leaves a migration-free schema alone. 

132 

133 UnmigratableTestModel has no registered migration despite being at major 

134 2, and reading a tree stamped at an older version must not raise a 

135 missing-step error: only a schema that has itself registered a migration 

136 should ever reach the chaining loop, regardless of what some unrelated 

137 schema (e.g. GapTestModel, registered above) has registered. Before the 

138 gate was fixed to check only this schema's own registrations, this exact 

139 read raised the gap error as soon as any other schema in the process had 

140 registered a migration. 

141 """ 

142 assert UnmigratableTestModel.SCHEMA_NAME not in {name for name, _ in _MIGRATIONS} 

143 tree = UnmigratableTestModel.model_validate( 

144 {"schema_version": "1.0.0", "min_read_version": 1, "required_at_v2": "present"} 

145 ) 

146 assert tree.required_at_v2 == "present" # type: ignore[attr-defined] 

147 assert tree.schema_version == "2.0.0" 

148 

149 

150def test_no_migration_schema_falls_through_to_a_validation_error() -> None: 

151 """Verify a migration-free schema falls through to ordinary Pydantic 

152 validation rather than a migration-gap error when the older tree 

153 genuinely cannot satisfy the current shape. 

154 

155 UnmigratableTestModel's ``required_at_v2`` has no default, so a tree that 

156 omits it fails Pydantic's own validation -- the other half of what 

157 :ref:`lsst.images-schema-versioning` promises for a schema with no 

158 migration: either in-model backfill handles the older tree (the test 

159 above), or Pydantic reports a validation error, but never the clean 

160 migration-gap error, which is reserved for a schema that has actually 

161 opted into migrations. 

162 """ 

163 with pytest.raises(pydantic.ValidationError): 

164 UnmigratableTestModel.model_validate({"schema_version": "1.0.0", "min_read_version": 1}) 

165 

166 

167def test_migration_gap_falls_through_a_left_to_right_union() -> None: 

168 """Verify a migration gap in one union member does not abort the union. 

169 

170 ``ArchiveReadError`` derives from `RuntimeError`, which Pydantic does not 

171 treat as a failed union candidate; the gap raises a narrower exception 

172 that is also a `ValueError`, so Pydantic moves on to the next variant 

173 instead of the whole union attempt failing. This is the shape of 

174 `~lsst.images._visit_image.VisitImageSerializationModel.psf`: a 

175 ``left_to_right`` union where an earlier variant may no longer accept an 

176 older tree while a later one still matches it directly. 

177 """ 

178 

179 class _Wrapper(pydantic.BaseModel): 

180 member: GapTestModel | ProjectionTestModel = pydantic.Field(union_mode="left_to_right") 

181 

182 wrapper = _Wrapper.model_validate({"member": {"schema_version": "1.0.0", "min_read_version": 1}}) 

183 assert isinstance(wrapper.member, ProjectionTestModel) 

184 

185 

186@pytest.mark.parametrize("stamp", ["not.a.version", "", 2, None]) 

187def test_malformed_stamp_falls_through_a_left_to_right_union(stamp: object) -> None: 

188 """Verify an unparsable stamp fails one variant, not the whole union. 

189 

190 The on-disk major is parsed in the same ``mode="before"`` validator as the 

191 migration chain, ahead of any field validation, so it runs for every 

192 migratable variant a union tries -- including variants the payload does 

193 not belong to. A stamp the reader cannot parse must therefore fail that 

194 one candidate the way a migration gap does, rather than abort the union. 

195 """ 

196 

197 class _Fallback(pydantic.BaseModel): 

198 schema_version: Any 

199 min_read_version: int 

200 

201 class _Wrapper(pydantic.BaseModel): 

202 member: MigrationTestModel | _Fallback = pydantic.Field(union_mode="left_to_right") 

203 

204 wrapper = _Wrapper.model_validate({"member": {"schema_version": stamp, "min_read_version": 1}}) 

205 assert isinstance(wrapper.member, _Fallback) 

206 

207 

208def test_malformed_stamp_names_the_unparsable_version() -> None: 

209 """Verify a standalone read reports which stamp could not be parsed. 

210 

211 Wrapped by Pydantic for the reason 

212 ``test_missing_step_raises_a_clean_error`` describes, with the message 

213 preserved. 

214 """ 

215 with pytest.raises((ArchiveReadError, pydantic.ValidationError), match="has non-integer major"): 

216 MigrationTestModel.model_validate({"schema_version": "x.0.0", "min_read_version": 1}) 

217 

218 

219def test_nested_subtree_migrates() -> None: 

220 """Verify a nested old sub-tree is morphed by its own registered step.""" 

221 

222 class _Parent(ArchiveTree): 

223 SCHEMA_NAME: ClassVar[str] = "migration_parent_test" 

224 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

225 MIN_READ_VERSION: ClassVar[int] = 1 

226 PUBLIC_TYPE: ClassVar[type] = dict 

227 

228 child: MigrationTestModel = pydantic.Field(description="An embedded sub-tree.") 

229 

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

231 return {"child": self.child.deserialize(archive)} 

232 

233 parent = _Parent.model_validate( 

234 { 

235 "schema_version": "1.0.0", 

236 "min_read_version": 1, 

237 "child": {"schema_version": "1.0.0", "min_read_version": 1, "original": "nested"}, 

238 } 

239 ) 

240 assert parent.child.renamed == "nested" 

241 

242 

243def test_migration_test_fixture_reads_from_disk() -> None: 

244 """Verify the declared projection divergence transforms correctly.""" 

245 fixtures = {f.version: f for f in iter_schema_fixtures(DOUBLE_DIR) if f.name == "migration_test"} 

246 tree = read_fixture_tree(fixtures["1.0.0"]) 

247 assert tree.renamed == "exemplar" # type: ignore[attr-defined] 

248 current = read_fixture_tree(fixtures["2.0.0"]) 

249 assert current.renamed == "exemplar" # type: ignore[attr-defined]