Coverage for python / lsst / images / tests / _roundtrip.py: 26%
95 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-23 22:23 -0700
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-23 22:23 -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.
12from __future__ import annotations
14__all__ = ("RoundtripFits", "TemporaryButler")
16import tempfile
17import unittest
18from contextlib import ExitStack
19from typing import Any, TypeVar
21import astropy.io.fits
23try:
24 from lsst.daf.butler import Butler, DataCoordinate, DatasetRef, DatasetType
26 HAVE_BUTLER = True
27except ImportError:
28 HAVE_BUTLER = False
30from .. import fits
32# We need an old-style TypeVar for Sphinx.
33T = TypeVar("T")
36class TemporaryButler:
37 """Make a temporary butler repository.
39 Parameters
40 ----------
41 run
42 Name of a `~lsst.daf.butler.CollectionType.RUN` collection to
43 register and use as the default run for the returned butler.
44 **kwargs
45 A mapping from a dataset type name to its storage class. For each
46 entry, a dataset type will be registered with empty dimensions, and a
47 `~lsst.daf.butler.DatasetRef` will be created and added as an
48 attribute of this class.
50 Raises
51 ------
52 unittest.SkipTest
53 Raised when the context manager is entered if `lsst.daf.butler` could
54 not be imported. This is typically handled by using this context
55 manager within a `unittest.TestCase.subTest` context, which will skip
56 just the butler-required tests in that context while allowing the rest
57 of the test to continue.
58 """
60 def __init__(self, run: str = "test_run", **kwargs: str):
61 self.run = run
62 self._kwargs = kwargs
63 self._exit_stack = ExitStack()
65 def __enter__(self) -> TemporaryButler:
66 if not HAVE_BUTLER:
67 raise unittest.SkipTest("lsst.daf.butler could not be imported.")
68 self._exit_stack.__enter__()
69 root = self._exit_stack.enter_context(
70 tempfile.TemporaryDirectory(ignore_cleanup_errors=True, delete=True)
71 )
72 butler_config = Butler.makeRepo(root)
73 self.butler = self._exit_stack.enter_context(Butler.from_config(butler_config, run=self.run))
74 empty_data_id = DataCoordinate.make_empty(self.butler.dimensions)
75 for name, storage_class in self._kwargs.items():
76 dataset_type = DatasetType(name, self.butler.dimensions.empty, storage_class)
77 try:
78 self.butler.registry.registerDatasetType(dataset_type)
79 except KeyError as err:
80 err.add_note(
81 "Storage class not configured in butler defaults. "
82 "A newer version of daf_butler may be needed."
83 )
84 raise
85 setattr(self, name, DatasetRef(dataset_type, empty_data_id, self.run))
86 return self
88 def __exit__(self, *args: Any) -> bool | None:
89 return self._exit_stack.__exit__(*args)
91 # Just for typing, since this class uses dynamic attributes.
92 def __getattr__(self, name: str) -> DatasetRef:
93 raise AttributeError(name)
96class RoundtripFits[T]:
97 """A context manager for testing FITS-based serialization.
99 Parameters
100 ----------
101 tc
102 A test case object to used for internal checks.
103 original
104 The object to serialize.
105 storage_class
106 A butler storage class name to use. If not provided (or
107 `lsst.daf.butler` cannot be imported), the roundtrip will just use
108 a direct write to a temporary file.
110 Notes
111 -----
112 When entered, this context manager writes the object and reads it back in
113 to the ``result`` attribute. When exited, any temporary files or
114 directories are deleted, but the ``result`` attribute is still usable.
115 In between the `inspect` and `get` methods can be used to perform other
116 tests.
117 """
119 def __init__(self, tc: unittest.TestCase, original: T, storage_class: str | None = None):
120 self._original = original
121 self._storage_class = storage_class
122 self._serialized: Any = None
123 self._exit_stack = ExitStack()
124 self._filename: str | None = None
125 self.result: Any
126 self.butler: Butler | None = None
127 self.ref: DatasetRef | None = None
129 def __enter__(self) -> RoundtripFits[T]:
130 self._exit_stack.__enter__()
131 if HAVE_BUTLER and self._storage_class is not None:
132 self._run_with_butler()
133 else:
134 self._run_without_butler()
135 return self
137 def __exit__(self, *args: Any) -> bool | None:
138 return self._exit_stack.__exit__(*args)
140 @property
141 def filename(self) -> str:
142 """The name of the file the object was written to."""
143 if self._filename is None:
144 assert self.butler is not None and self.ref is not None
145 self._filename = self.butler.getURI(self.ref).ospath
146 return self._filename
148 @property
149 def serialized(self) -> Any:
150 """The serialization model for this object
151 (`.serialization.ArchiveTree`).
152 """
153 if self._serialized is None:
154 # The butler code path doesn't give us a way to inspect the
155 # serialized model, so we have to save it again directly to another
156 # file (which we then discard).
157 with tempfile.NamedTemporaryFile(suffix=".fits", delete_on_close=False, delete=True) as tmp:
158 tmp.close()
159 self._serialized = fits.write(self._original, tmp.name)
160 return self._serialized
162 def inspect(self) -> astropy.io.fits.HDUList:
163 """Open the FITS file with Astropy."""
164 return self._exit_stack.enter_context(
165 astropy.io.fits.open(self.filename, disable_image_compression=True)
166 )
168 def get(self, component: str | None = None, **kwargs: Any) -> Any:
169 """Perform a partial read.
171 Parameters
172 ----------
173 component
174 Component to read instead of the main object. This requires the
175 roundtrip to use a butler, raising `unittest.SkipTest` otherwise;
176 this generally means these tests should be nested within a
177 `~unittest.TestCase.subTest` context.
178 **kwargs
179 Keyword arguments either passed directly to `.fits.read` or used
180 as ``parameters`` for a `~lsst.daf.butler.Butler.get`.
182 Return
183 ------
184 object
185 Result of the partial read.
186 """
187 if self.butler is None:
188 if component is not None:
189 raise unittest.SkipTest("Cannot test component reads without a butler.")
190 return fits.read(type(self._original), self.filename, **kwargs)
191 else:
192 assert self.ref is not None, "butler and ref should be None or not together"
193 ref = self.ref
194 if component is not None:
195 ref = ref.makeComponentRef(component)
196 return self.butler.get(ref, parameters=kwargs)
198 def _run_with_butler(self) -> None:
199 assert self._storage_class is not None, "Should not use butler if no storage class"
200 butler_helper = self._exit_stack.enter_context(TemporaryButler(test_dataset=self._storage_class))
201 self.butler = butler_helper.butler
202 self.ref = self.butler.put(self._original, butler_helper.test_dataset)
203 self.result = self.butler.get(self.ref)
205 def _run_without_butler(self) -> None:
206 tmp = self._exit_stack.enter_context(
207 tempfile.NamedTemporaryFile(suffix=".fits", delete_on_close=False, delete=True)
208 )
209 tmp.close()
210 self._filename = tmp.name
211 self._serialized = fits.write(self._original, tmp.name)
212 self.result = fits.read(type(self._original), tmp.name)