Coverage for python/lsst/images/serialization/_input_archive.py: 100%
64 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-08 09:10 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-08 09:10 +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.
12from __future__ import annotations
14__all__ = ("ArchiveInfo", "DetachedArchive", "InputArchive")
16from abc import ABC, abstractmethod
17from collections.abc import Callable
18from contextlib import AbstractContextManager
19from types import EllipsisType
20from typing import IO, TYPE_CHECKING, Any, TypeVar
22import astropy.io.fits
23import astropy.table
24import astropy.units
25import numpy as np
26import pydantic
28from lsst.resources import ResourcePath, ResourcePathExpression
30from ._asdf_utils import ArrayReferenceModel, InlineArrayModel
31from ._common import (
32 SCHEMA_URL_HOST,
33 ArchiveAccessRequiredError,
34 ArchiveTree,
35 OpaqueArchiveMetadata,
36 no_header_updates,
37)
38from ._tables import TableModel
40if TYPE_CHECKING:
41 from .._transforms import FrameSet
44# This pre-python-3.12 declaration is needed by Sphinx (probably the
45# autodoc-typehints plugin.
46P = TypeVar("P", bound=pydantic.BaseModel)
49class ArchiveInfo(pydantic.BaseModel, frozen=True):
50 """Basic identifying information about an on-disk archive.
52 Read from a file's headers/metadata without deserializing pixel data.
53 """
55 schema_url: str
56 """Canonical schema URL of the top-level tree."""
58 schema_name: str
59 """Schema name parsed from ``schema_url``."""
61 schema_version: str
62 """Schema version parsed from ``schema_url``."""
64 format_version: int | None
65 """Container layout version (FITS ``FMTVER`` / NDF ``FORMAT_VERSION``);
66 `None` for formats with no separate container version (JSON)."""
68 @classmethod
69 def from_schema_url(cls, schema_url: str, *, format_version: int | None) -> ArchiveInfo:
70 """Build an `ArchiveInfo` by parsing a schema URL of the form
71 ``https://images.lsst.io/schemas/{name}-{version}``.
73 The URL is parsed with `~lsst.resources.ResourcePath` and its
74 hostname must be ``images.lsst.io``, so a ``DATAMODL`` header written
75 by an unrelated tool cannot steer reads toward an arbitrary schema.
77 Parameters
78 ----------
79 schema_url
80 Schema URL to parse for the schema name and version.
81 format_version
82 Container layout version, or `None` for formats with no
83 separate container version.
84 """
85 parsed = ResourcePath(schema_url)
86 if parsed.netloc != SCHEMA_URL_HOST:
87 raise ValueError(
88 f"Schema URL {schema_url!r} is not hosted at {SCHEMA_URL_HOST!r}; "
89 "this file was not written by lsst.images."
90 )
91 tail = parsed.basename()
92 # Split on the last hyphen: schema names may contain hyphens; the
93 # version (after the final hyphen) is assumed not to.
94 name, _, version = tail.rpartition("-")
95 if not name or not version:
96 raise ValueError(f"Cannot parse schema name/version from URL {schema_url!r}.")
97 return cls(
98 schema_url=schema_url,
99 schema_name=name,
100 schema_version=version,
101 format_version=format_version,
102 )
105class InputArchive[P: pydantic.BaseModel](ABC):
106 """Abstract interface for reading from a file format.
108 Notes
109 -----
110 An input archive instance is assumed to be paired with a Pydantic model
111 that represents a JSON tree, with the archive used to deserialize data that
112 is not native JSON from data that is (which may just be a reference to
113 binary data stored elsewhere in the file). The archive doesn't actually
114 hold that model instance because we'd prefer to avoid making the input
115 archive generic over the model type. It is expected that most concrete
116 archive implementations will provide a method to load the paired model from
117 a file, but this is not part of the base class interface.
118 """
120 @classmethod
121 def get_basic_info(cls, path: ResourcePathExpression) -> ArchiveInfo:
122 """Return basic identifying information for the archive at ``path``
123 without deserializing pixel data.
125 Each concrete backend reads only the headers/metadata it needs.
127 Parameters
128 ----------
129 path
130 Path to the archive to read.
131 """
132 raise NotImplementedError(f"{cls.__name__} does not implement get_basic_info.")
134 @classmethod
135 def open_tree(
136 cls,
137 path: ResourcePathExpression | IO[bytes],
138 *,
139 partial: bool = True,
140 **backend_kwargs: Any,
141 ) -> AbstractContextManager[tuple[InputArchive[P], ArchiveTree, ArchiveInfo]]:
142 """Open ``path``, load and validate its top-level tree, and yield
143 ``(archive, tree, info)`` as a context manager.
145 Parameters
146 ----------
147 path
148 File to be opened (local or remote), or a seekable binary
149 stream containing the file's content.
150 partial
151 Whether the file should be opened for incremental reads or not.
152 Can be ignored by a backend where not relevant.
153 **backend_kwargs
154 Any keyword parameters that should be forwarded to the backend
155 open.
157 Raises
158 ------
159 ArchiveReadError
160 If the file's schema is not registered.
162 Notes
163 -----
164 Each concrete backend implements this.
165 """
166 raise NotImplementedError(f"{cls.__name__} does not implement open_tree.")
168 @abstractmethod
169 def deserialize_pointer[U: ArchiveTree, V](
170 self, pointer: P, model_type: type[U], deserializer: Callable[[U, InputArchive[P]], V]
171 ) -> V:
172 """Deserialize an object that was saved by
173 `~lsst.serialization.OutputArchive.serialize_pointer`.
175 Parameters
176 ----------
177 pointer
178 JSON Pointer model to dereference.
179 model_type
180 Pydantic model type that the pointer should dereference to.
181 deserializer
182 Callable that takes an instance of ``model_type`` and an input
183 archive, and returns the deserialized object.
185 Returns
186 -------
187 V
188 The deserialized object.
190 Notes
191 -----
192 Implementations are required to remember previously-deserialized
193 objects and return them when the same pointer is passed in multiple
194 times.
196 There is no ``deserialize_direct`` (to pair with
197 `~lsst.serialization.OutputArchive.serialize_direct`) because the
198 caller can just call a deserializer function directly on a sub-model
199 of its Pydantic tree.
200 """
201 raise NotImplementedError()
203 @abstractmethod
204 def get_frame_set(self, ref: P) -> FrameSet:
205 """Return an already-deserialized frame set from the archive.
207 Parameters
208 ----------
209 ref
210 Implementation-specific reference to the frame set.
212 Returns
213 -------
214 FrameSet
215 Loaded frame set.
216 """
217 raise NotImplementedError()
219 @abstractmethod
220 def get_array(
221 self,
222 model: ArrayReferenceModel | InlineArrayModel,
223 *,
224 slices: tuple[slice, ...] | EllipsisType = ...,
225 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
226 ) -> np.ndarray:
227 """Load an array from the archive.
229 Parameters
230 ----------
231 model
232 A Pydantic model that references or holds the array.
233 slices
234 Slices that specify a subset of the original array to read.
235 strip_header
236 A callable that strips out any FITS header cards added by the
237 ``update_header`` argument in the corresponding call to
238 `~lsst.images.serialization.OutputArchive.add_array`.
239 """
240 raise NotImplementedError()
242 @abstractmethod
243 def get_table(
244 self,
245 model: TableModel,
246 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
247 ) -> astropy.table.Table:
248 """Load a table from the archive.
250 Parameters
251 ----------
252 model
253 A Pydantic model that references or holds the table.
254 strip_header
255 A callable that strips out any FITS header cards added by the
256 ``update_header`` argument in the corresponding call to
257 `~lsst.serialization.OutputArchive.add_table`.
259 Returns
260 -------
261 astropy.table.Table
262 The loaded table.
263 """
264 raise NotImplementedError()
266 @abstractmethod
267 def get_structured_array(
268 self,
269 model: TableModel,
270 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
271 ) -> np.ndarray:
272 """Load a table from the archive as a structured array.
274 Parameters
275 ----------
276 model
277 A Pydantic model that references or holds the table.
278 strip_header
279 A callable that strips out any FITS header cards added by the
280 ``update_header`` argument in the corresponding call to
281 `~lsst.serialization.OutputArchive.add_structured_array`.
283 Returns
284 -------
285 numpy.ndarray
286 The loaded table as a structured array.
287 """
288 raise NotImplementedError()
290 def get_opaque_metadata(self) -> OpaqueArchiveMetadata | None:
291 """Return opaque metadata loaded from the file that should be saved if
292 another version of the object is saved to the same file format.
294 Returns
295 -------
296 OpaqueArchiveMetadata
297 Opaque metadata specific to this archive type that should be
298 round-tripped if it is saved in the same format.
299 """
300 return None
303class DetachedArchive(InputArchive[Any]):
304 """An input archive that is not attached to any file.
306 Every method that would read data from a file raises
307 `ArchiveAccessRequiredError`.
309 Notes
310 -----
311 Passing an instance to `ArchiveTree.deserialize_component` probes
312 whether a component can be deserialized from the tree alone: success
313 means no file access was needed, while `ArchiveAccessRequiredError`
314 means the caller must use a live archive instead. Instances hold no
315 state, so a single instance can be shared by any number of probes.
316 """
318 def deserialize_pointer[U: ArchiveTree, V](
319 self, pointer: Any, model_type: type[U], deserializer: Callable[[U, InputArchive[Any]], V]
320 ) -> V:
321 # Docstring inherited.
322 raise ArchiveAccessRequiredError("Dereferencing an archive pointer requires file access.")
324 def get_frame_set(self, ref: Any) -> FrameSet:
325 # Docstring inherited.
326 raise ArchiveAccessRequiredError("Reading a frame set requires file access.")
328 def get_array(
329 self,
330 model: ArrayReferenceModel | InlineArrayModel,
331 *,
332 slices: tuple[slice, ...] | EllipsisType = ...,
333 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
334 ) -> np.ndarray:
335 # Docstring inherited.
336 raise ArchiveAccessRequiredError("Reading an array requires file access.")
338 def get_table(
339 self,
340 model: TableModel,
341 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
342 ) -> astropy.table.Table:
343 # Docstring inherited.
344 raise ArchiveAccessRequiredError("Reading a table requires file access.")
346 def get_structured_array(
347 self,
348 model: TableModel,
349 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
350 ) -> np.ndarray:
351 # Docstring inherited.
352 raise ArchiveAccessRequiredError("Reading a structured array requires file access.")