Coverage for tests/test_schema_fixtures.py: 94%

110 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-01 09:54 +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 committed schema fixtures, checked against the live models. 

12 

13Every fixture is read through its model on every run, so a model change that 

14alters what a file looks like fails here rather than passing silently. See 

15:ref:`lsst.images-schema-versioning` for the lifecycle these checks enforce. 

16""" 

17 

18from __future__ import annotations 

19 

20import json 

21from pathlib import Path 

22 

23import pytest 

24 

25# Importing this registers the purpose-built schemas whose fixtures live under 

26# fixture_doubles/. It is a plain module rather than a test module so the 

27# registration cannot depend on pytest's collection order. 

28import schema_doubles # noqa: F401 

29from packaging.version import Version 

30 

31from lsst.images.serialization import ( 

32 ArchiveReadError, 

33 JsonRef, 

34 dump_schema, 

35 is_development_version, 

36 parameterize_tree, 

37 read_archive, 

38) 

39from lsst.images.tests import ( 

40 SchemaFixture, 

41 canonical_fixture_text, 

42 check_schema_fixtures, 

43 compare_fixture_versions, 

44 fixture_version, 

45 iter_schema_fixtures, 

46 read_fixture_tree, 

47) 

48 

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

50SCHEMA_DIR = Path(__file__).parent.parent / "schemas" 

51 

52NO_FIXTURE = { 

53 "psfex_psf": "needs PSFEx data this package cannot construct", 

54} 

55"""Schemas allowed to have no fixture, with the reason each. 

56 

57Kept beside ``_DEVELOPMENT_SCHEMAS`` in style: an explicit list, so adding a 

58schema without a fixture is a deliberate, reviewable act. 

59""" 

60 

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

62 

63# The ladder runs over the package's own fixtures and over the purpose-built 

64# doubles, so every rung -- including the cross-version projection, which no 

65# lsst.images schema can exercise yet -- has real cases from day one. 

66_FIXTURES = sorted( 

67 [*iter_schema_fixtures(FIXTURE_DIR), *iter_schema_fixtures(DOUBLE_DIR)], 

68 key=lambda f: f.path, 

69) 

70 

71_READABLE = [f for f in _FIXTURES if not f.retired] 

72_RETIRED = [f for f in _FIXTURES if f.retired] 

73 

74 

75def _id(fixture: SchemaFixture) -> str: 

76 return fixture.path.name 

77 

78 

79def test_fixtures_present() -> None: 

80 """Verify the fixture tree is populated.""" 

81 assert _READABLE, f"no fixtures found in {FIXTURE_DIR}" 

82 

83 

84def test_check_schema_fixtures_is_clean() -> None: 

85 """Verify the committed fixture tree has no reported problems. 

86 

87 A failure here names the remedy: 'fixtures refresh' for a development 

88 schema, or a SCHEMA_VERSION bump for a finalized one. 

89 """ 

90 problems = check_schema_fixtures(FIXTURE_DIR, schema_directory=SCHEMA_DIR, exempt=NO_FIXTURE) 

91 assert not problems, "\n".join(problems) 

92 

93 

94def test_every_schema_without_a_fixture_has_a_recorded_reason() -> None: 

95 """Verify the exemption list has not grown stale. 

96 

97 An exemption for a schema that now has a fixture should be deleted. 

98 """ 

99 have = {f.name for f in _READABLE} 

100 assert not (set(NO_FIXTURE) & have), "exempt schemas that now have fixtures" 

101 

102 

103@pytest.mark.parametrize("fixture", _READABLE, ids=_id) 

104def test_fixture_reads(fixture: SchemaFixture) -> None: 

105 """Verify every retained fixture validates through its live model.""" 

106 assert fixture.tree_cls is not None, f"{fixture.name} is not registered" 

107 tree = read_fixture_tree(fixture) 

108 assert tree.schema_version == fixture.tree_cls.SCHEMA_VERSION 

109 

110 

111def _older_release(schema_version: str) -> str | None: 

112 """Return a release strictly older than ``schema_version``'s release. 

113 

114 Operates on the release components only (major, minor, patch), ignoring 

115 any development-release suffix, so the result is genuinely older even 

116 when ``schema_version`` is itself a development release. The rightmost 

117 nonzero component is decremented, which is always the release 

118 immediately below in version ordering. 

119 

120 Parameters 

121 ---------- 

122 schema_version 

123 Schema version string, e.g. ``1.0.0`` or ``1.0.0.dev0``. 

124 

125 Returns 

126 ------- 

127 `str` or `None` 

128 The older release string, or `None` if the release is already 

129 ``0.0.0`` and so has no older release to name. 

130 """ 

131 version = Version(schema_version) 

132 major, minor, patch = version.major, version.minor, version.micro 

133 if patch > 0: 133 ↛ 134line 133 didn't jump to line 134 because the condition on line 133 was never true

134 return f"{major}.{minor}.{patch - 1}" 

135 if minor > 0: 

136 return f"{major}.{minor - 1}.0" 

137 if major > 0: 137 ↛ 139line 137 didn't jump to line 139 because the condition on line 137 was always true

138 return f"{major - 1}.0.0" 

139 return None 

140 

141 

142@pytest.mark.parametrize("fixture", _READABLE, ids=_id) 

143def test_fixture_upgrades_on_write(fixture: SchemaFixture) -> None: 

144 """Verify a tree stamped at an older version re-stamps at the live one. 

145 

146 One model class serves every version of its schema, so reading an older, 

147 compatible tree and writing it back should emit the current shape and 

148 stamps. Re-reading the fixture's own output would only restate what 

149 `~lsst.images.serialization.ArchiveTree` normalization already produced, 

150 so this instead fabricates an older ``schema_version`` / ``schema_url`` / 

151 ``min_read_version`` on the fixture's payload -- in memory only, the 

152 committed file is never touched -- and validates that mutated payload 

153 directly, exercising the normalization rather than restating its result. 

154 """ 

155 tree_cls = fixture.tree_cls 

156 assert tree_cls is not None 

157 older = _older_release(tree_cls.SCHEMA_VERSION) 

158 if older is None: 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true

159 pytest.skip(f"{tree_cls.SCHEMA_NAME} has no older release to stamp the payload with") 

160 if ( 

161 Version(older).major != Version(tree_cls.SCHEMA_VERSION).major 

162 and Version(tree_cls.SCHEMA_VERSION).major > 1 

163 ): 

164 # A relabeled-only payload keeps every current-shape field, so it 

165 # only stands in for a genuinely older tree within the same major: 

166 # nothing was renamed, split, or removed there, so the current model 

167 # accepts it unchanged. Once a schema has moved past major 1, an 

168 # older major may need a registered migration to bridge a real shape 

169 # difference, and this relabel-only payload cannot exercise that; the 

170 # migration chain tests and the projection oracle cover cross-major 

171 # compatibility instead. No schema in this package has moved past 

172 # major 1 yet, so this never skips real coverage today. 

173 pytest.skip(f"{tree_cls.SCHEMA_NAME} has moved past major 1; not a relabel-only case") 

174 payload = json.loads(fixture.path.read_text()) 

175 payload["schema_version"] = older 

176 payload["min_read_version"] = 1 

177 payload["schema_url"] = f"{tree_cls.SCHEMA_URL_BASE}/{tree_cls.SCHEMA_NAME}-{older}" 

178 parameterized = parameterize_tree(tree_cls, JsonRef) 

179 tree = parameterized.model_validate_json(json.dumps(payload)) 

180 dumped = json.loads(canonical_fixture_text(tree)) 

181 assert dumped["schema_version"] == tree_cls.SCHEMA_VERSION 

182 assert dumped["min_read_version"] == tree_cls.MIN_READ_VERSION 

183 assert dumped["schema_url"] == ( 

184 f"{tree_cls.SCHEMA_URL_BASE}/{tree_cls.SCHEMA_NAME}-{tree_cls.SCHEMA_VERSION}" 

185 ) 

186 

187 

188@pytest.mark.parametrize("fixture", _READABLE, ids=_id) 

189def test_fixture_is_canonical(fixture: SchemaFixture) -> None: 

190 """Verify a same-version fixture round-trips byte for byte. 

191 

192 An as_shipped fixture is exempt: re-serializing would rewrite the shipped 

193 spelling it exists to preserve, and its canonical twin carries the 

194 pairwise check instead. 

195 """ 

196 assert fixture.tree_cls is not None, f"{fixture.name} is not registered" 

197 if fixture.is_as_shipped: 

198 pytest.skip("as_shipped fixtures preserve real bytes and are not canonicalized") 

199 if fixture.version != fixture_version(fixture.tree_cls.SCHEMA_VERSION): 

200 pytest.skip("older retained version; covered by the projection check") 

201 assert fixture.path.read_text() == canonical_fixture_text(read_fixture_tree(fixture)) 

202 

203 

204@pytest.mark.parametrize("fixture", _READABLE, ids=_id) 

205def test_fixture_conforms_to_its_schema_document(fixture: SchemaFixture) -> None: 

206 """Verify every fixture validates against a draft 2020-12 schema. 

207 

208 A frozen version validates against its committed document, which also 

209 proves every reference inside the published document resolves. A 

210 development version validates against the live generated schema, so a 

211 development fixture is no longer skipped as it was before this ticket. 

212 

213 A fixture whose schema looks finalized (no ``.dev`` suffix) but has no 

214 frozen document under ``schemas/`` -- true of every purpose-built double 

215 in ``schema_doubles``, which is never frozen or published -- falls back 

216 to the live generated schema as well, provided the fixture is at that 

217 schema's live version; an older fixture of such a schema has no document 

218 of any kind to validate against and is still skipped. 

219 """ 

220 jsonschema = pytest.importorskip("jsonschema") 

221 tree_cls = fixture.tree_cls 

222 assert tree_cls is not None 

223 live_version = fixture_version(tree_cls.SCHEMA_VERSION) 

224 if is_development_version(tree_cls.SCHEMA_VERSION): 

225 schema = dump_schema(tree_cls) 

226 else: 

227 document = SCHEMA_DIR / fixture.name / f"{fixture.name}-{fixture.version}.json" 

228 if document.exists(): 

229 schema = json.loads(document.read_text()) 

230 elif fixture.version == live_version: 

231 schema = dump_schema(tree_cls) 

232 else: 

233 pytest.skip(f"{document} is not committed") 

234 jsonschema.Draft202012Validator(schema).validate(json.loads(fixture.path.read_text())) 

235 

236 

237@pytest.mark.parametrize("fixture", _READABLE, ids=_id) 

238def test_fixture_deserializes(fixture: SchemaFixture) -> None: 

239 """Verify the tree yields its in-memory object where deps allow. 

240 

241 A fixture whose deserialization needs an optional dependency (Piff, 

242 PSFEx, lsst.afw) raises ArchiveReadError at the point of use rather than 

243 at read time, so that outcome is accepted here. 

244 """ 

245 try: 

246 obj = read_archive(fixture.path) 

247 except ArchiveReadError as exc: 

248 pytest.skip(f"deserialization needs an unavailable dependency: {exc}") 

249 assert obj is not None 

250 

251 

252@pytest.mark.parametrize("fixture", _RETIRED, ids=_id) 

253def test_retired_fixture_is_rejected(fixture: SchemaFixture) -> None: 

254 """Verify a retired fixture raises rather than reading. 

255 

256 Rejection is a contract worth testing: retiring a fixture is how a read 

257 contract ends, and this asserts the new behavior rather than merely 

258 stopping to test the old one. 

259 """ 

260 with pytest.raises(ArchiveReadError): 

261 read_fixture_tree(fixture) 

262 

263 

264EXPECTED_DIVERGENCE: dict[tuple[str, str], str] = { 

265 ("migration_test", "1.0.0"): ( 

266 "the 1-to-2 migration renames 'original' to 'renamed'; " 

267 "test_migration_test_fixture_reads_from_disk asserts the transformed value" 

268 ), 

269} 

270"""Fixture pairs whose projection is expected to fail, with the reason each. 

271 

272A migration that renames or restructures a field defeats path-based 

273comparison by construction, and does so silently: the renamed path is absent 

274from the older file, so the oracle reads it as later-born and never compares 

275it, leaving a pair that passes while proving nothing about the migration. 

276Such a pair is registered here and its migration test asserts the morphed 

277result directly. This is the sole registry of those declarations, and the 

278only entry is a purpose-built double: no shipped schema has a second version 

279yet. 

280""" 

281 

282 

283def _older_fixtures() -> list[SchemaFixture]: 

284 """Return every retained fixture at less than its schema's live version.""" 

285 return [ 

286 f 

287 for f in _READABLE 

288 if f.tree_cls is not None and f.version != fixture_version(f.tree_cls.SCHEMA_VERSION) 

289 ] 

290 

291 

292@pytest.mark.parametrize("fixture", _older_fixtures(), ids=_id) 

293def test_older_fixture_projects_onto_the_current_one(fixture: SchemaFixture) -> None: 

294 """Verify an older fixture reads to the same exemplar as the current one. 

295 

296 Reading proves acceptance; this proves the result is right. Paths the 

297 older file could not express are ignored, so additive evolution needs no 

298 hand-written expectations. 

299 """ 

300 assert fixture.tree_cls is not None, f"{fixture.name} is not registered" 

301 if (reason := EXPECTED_DIVERGENCE.get((fixture.name, fixture.version))) is not None: 

302 pytest.skip(f"expected divergence: {reason}") 

303 current = next( 

304 f 

305 for f in _READABLE 

306 if f.name == fixture.name 

307 and f.variant == fixture.variant 

308 and f.version == fixture_version(fixture.tree_cls.SCHEMA_VERSION) 

309 ) 

310 problems = compare_fixture_versions( 

311 json.loads(canonical_fixture_text(read_fixture_tree(fixture))), 

312 json.loads(canonical_fixture_text(read_fixture_tree(current))), 

313 on_disk=json.loads(fixture.path.read_text()), 

314 ) 

315 assert not problems, "\n".join(problems) 

316 

317 

318def test_every_expected_divergence_names_a_real_fixture_pair() -> None: 

319 """Verify the escape hatch has not gone stale. 

320 

321 An entry naming a pair the ladder no longer runs skips nothing, so it no 

322 longer records a reviewed decision and should be deleted. Without this, 

323 a divergence declared for a fixture that was retired or renamed would sit 

324 there reading as coverage. 

325 """ 

326 declared = set(EXPECTED_DIVERGENCE) 

327 older = {(f.name, f.version) for f in _older_fixtures()} 

328 assert not (declared - older), "expected-divergence entries with no matching older fixture"