Coverage for tests/test_serialization_registry.py: 96%
86 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-23 01:52 -0700
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-23 01:52 -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.
11from __future__ import annotations
13import unittest
14from typing import Any, ClassVar
15from unittest import mock
17from lsst.images import SkyProjection, VisitImage
18from lsst.images._image import ImageSerializationModel
19from lsst.images._visit_image import VisitImageSerializationModel
20from lsst.images.serialization import ArchiveTree, _io, class_for_schema, public_type_for_schema
21from lsst.images.serialization._io import _REGISTRY, register_schema_class
24class ClassForSchemaTestCase(unittest.TestCase):
25 """class_for_schema returns None for unknown schema names."""
27 def test_unknown_returns_none(self) -> None:
28 self.assertIsNone(class_for_schema("does-not-exist"))
31class RegistrationTestCase(unittest.TestCase):
32 """ArchiveTree subclasses register themselves under SCHEMA_NAME."""
34 def test_visit_image_registered(self) -> None:
35 cls = class_for_schema("visit_image")
36 self.assertIs(cls, VisitImageSerializationModel)
38 def test_nested_image_registered(self) -> None:
39 # Nested types are registered too -- "register all" was the spec
40 # decision so callers can read sub-models directly.
41 cls = class_for_schema("image")
42 self.assertIs(cls, ImageSerializationModel)
44 def test_duplicate_registration_raises(self) -> None:
45 # Re-registering the same class is a no-op.
46 register_schema_class(VisitImageSerializationModel)
48 # Defining a subclass that redeclares SCHEMA_NAME triggers
49 # __pydantic_init_subclass__, which calls register_schema_class
50 # with a different class object under an existing name. That
51 # call raises RuntimeError.
52 with self.assertRaises(RuntimeError):
54 class _Imposter(VisitImageSerializationModel): # type: ignore[misc]
55 SCHEMA_NAME: ClassVar[str] = "visit_image"
56 SCHEMA_VERSION: ClassVar[str] = "1.0.0"
58 def test_builtin_provider_loaded_on_miss(self) -> None:
59 schema_names = ("cell_coadd", "cell_psf", "coadd_provenance")
60 saved = {schema_name: _REGISTRY.pop(schema_name, None) for schema_name in schema_names}
61 try:
62 for schema_name in schema_names:
63 with self.subTest(schema_name=schema_name):
64 cls = class_for_schema(schema_name)
65 self.assertIsNotNone(cls)
66 self.assertEqual(cls.SCHEMA_NAME, schema_name)
67 finally:
68 # Preserve any registrations that existed before this test, even
69 # if an assertion above fails.
70 _REGISTRY.update({schema_name: cls for schema_name, cls in saved.items() if cls is not None})
72 def test_entry_point_provider_loaded_on_miss(self) -> None:
73 class _EntryPointTree(ArchiveTree):
74 SCHEMA_NAME: ClassVar[str] = "_entry_point_schema_test"
75 SCHEMA_VERSION: ClassVar[str] = "1.0.0"
76 MIN_READ_VERSION: ClassVar[int] = 1
78 def deserialize(self, archive: Any, **kwargs: Any) -> VisitImage:
79 raise AssertionError("not used")
81 class _FakeEntryPoint:
82 value = "tests.test_serialization_registry:_EntryPointTree"
84 def load(self) -> type[ArchiveTree]:
85 return _EntryPointTree
87 _REGISTRY.pop("_entry_point_schema_test", None)
88 try:
89 with mock.patch.object(
90 _io.importlib.metadata,
91 "entry_points",
92 return_value=[_FakeEntryPoint()],
93 ) as entry_points:
94 cls = class_for_schema("_entry_point_schema_test")
95 entry_points.assert_called_once_with(
96 group="lsst.images.schemas",
97 name="_entry_point_schema_test",
98 )
99 self.assertIs(cls, _EntryPointTree)
100 finally:
101 _REGISTRY.pop("_entry_point_schema_test", None)
104class PublicTypeTestCase(unittest.TestCase):
105 """public_type_for_schema returns each tree's PUBLIC_TYPE ClassVar."""
107 def test_concrete_type(self) -> None:
108 self.assertIs(public_type_for_schema("visit_image"), VisitImage)
110 def test_generic_in_memory_type(self) -> None:
111 # ProjectionSerializationModel produces a Projection (its PUBLIC_TYPE
112 # is the unparameterised class, not Projection[Any]).
113 self.assertIs(public_type_for_schema("sky_projection"), SkyProjection)
115 def test_unregistered_schema_returns_none(self) -> None:
116 self.assertIsNone(public_type_for_schema("no-such-schema"))
119def _all_concrete_archive_tree_subclasses() -> list[type]:
120 """Walk ArchiveTree's subclass tree and return all concrete subclasses
121 that declare SCHEMA_NAME (i.e. are real schema-bearing leaves).
122 """
123 seen: list[type] = []
124 stack: list[type] = list(ArchiveTree.__subclasses__())
125 while stack:
126 cls = stack.pop()
127 stack.extend(cls.__subclasses__())
128 if "SCHEMA_NAME" in cls.__dict__:
129 seen.append(cls)
130 return seen
133class ClassInvariantsTestCase(unittest.TestCase):
134 """Every ArchiveTree subclass with SCHEMA_NAME is registered and has a
135 resolvable, concrete deserialize return annotation.
136 """
138 def test_every_subclass_registered(self) -> None:
139 # Test-local classes from other test methods may linger in
140 # __subclasses__ after their cleanup runs; restrict the check to
141 # classes whose modules belong to the package itself.
142 missing: list[str] = []
143 for cls in _all_concrete_archive_tree_subclasses():
144 if not cls.__module__.startswith("lsst.images"):
145 continue
146 registered = _REGISTRY.get(cls.SCHEMA_NAME)
147 if registered is None or registered is not cls: 147 ↛ 148line 147 didn't jump to line 148 because the condition on line 147 was never true
148 missing.append(f"{cls.__qualname__} -> {cls.SCHEMA_NAME}")
149 self.assertEqual(missing, [], f"Unregistered subclasses: {missing}")
151 def test_every_registered_class_declares_public_type(self) -> None:
152 # Restrict to package-local classes; test-only ArchiveTree
153 # subclasses (e.g. tests/test_schema_versioning.py's
154 # _DummyArchiveTree) may register but are not part of the package.
155 unresolved: list[str] = []
156 for cls in _REGISTRY.values():
157 if not cls.__module__.startswith("lsst.images"):
158 continue
159 if not isinstance(getattr(cls, "PUBLIC_TYPE", None), type): 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
160 unresolved.append(cls.__qualname__)
161 self.assertEqual(unresolved, [], f"No PUBLIC_TYPE declared: {unresolved}")
164if __name__ == "__main__":
165 unittest.main()