Coverage for tests/test_schema_fixtures.py: 94%
110 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-29 02:47 -0700
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-29 02:47 -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"""The committed schema fixtures, checked against the live models.
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"""
18from __future__ import annotations
20import json
21from pathlib import Path
23import pytest
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
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)
49FIXTURE_DIR = Path(__file__).parent / "data" / "schemas"
50SCHEMA_DIR = Path(__file__).parent.parent / "schemas"
52NO_FIXTURE = {
53 "psfex_psf": "needs PSFEx data this package cannot construct",
54}
55"""Schemas allowed to have no fixture, with the reason each.
57Kept beside ``_DEVELOPMENT_SCHEMAS`` in style: an explicit list, so adding a
58schema without a fixture is a deliberate, reviewable act.
59"""
61DOUBLE_DIR = Path(__file__).parent / "data" / "fixture_doubles"
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)
71_READABLE = [f for f in _FIXTURES if not f.retired]
72_RETIRED = [f for f in _FIXTURES if f.retired]
75def _id(fixture: SchemaFixture) -> str:
76 return fixture.path.name
79def test_fixtures_present() -> None:
80 """Verify the fixture tree is populated."""
81 assert _READABLE, f"no fixtures found in {FIXTURE_DIR}"
84def test_check_schema_fixtures_is_clean() -> None:
85 """Verify the committed fixture tree has no reported problems.
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)
94def test_every_schema_without_a_fixture_has_a_recorded_reason() -> None:
95 """Verify the exemption list has not grown stale.
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"
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
111def _older_release(schema_version: str) -> str | None:
112 """Return a release strictly older than ``schema_version``'s release.
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.
120 Parameters
121 ----------
122 schema_version
123 Schema version string, e.g. ``1.0.0`` or ``1.0.0.dev0``.
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
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.
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 )
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.
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))
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.
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.
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()))
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.
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
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.
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)
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.
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"""
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 ]
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.
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)
318def test_every_expected_divergence_names_a_real_fixture_pair() -> None:
319 """Verify the escape hatch has not gone stale.
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"