Coverage for python/lsst/images/serialization/_input_archive.py: 100%
64 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-01 09:14 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-01 09:14 +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 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,
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. Can be local or remote.
149 partial
150 Whether the file should be opened for incremental reads or not.
151 Can be ignored by a backend where not relevant.
152 **backend_kwargs
153 Any keyword parameters that should be forwarded to the backend
154 open.
156 Raises
157 ------
158 ArchiveReadError
159 If the file's schema is not registered.
161 Notes
162 -----
163 Each concrete backend implements this.
164 """
165 raise NotImplementedError(f"{cls.__name__} does not implement open_tree.")
167 @abstractmethod
168 def deserialize_pointer[U: ArchiveTree, V](
169 self, pointer: P, model_type: type[U], deserializer: Callable[[U, InputArchive[P]], V]
170 ) -> V:
171 """Deserialize an object that was saved by
172 `~lsst.serialization.OutputArchive.serialize_pointer`.
174 Parameters
175 ----------
176 pointer
177 JSON Pointer model to dereference.
178 model_type
179 Pydantic model type that the pointer should dereference to.
180 deserializer
181 Callable that takes an instance of ``model_type`` and an input
182 archive, and returns the deserialized object.
184 Returns
185 -------
186 V
187 The deserialized object.
189 Notes
190 -----
191 Implementations are required to remember previously-deserialized
192 objects and return them when the same pointer is passed in multiple
193 times.
195 There is no ``deserialize_direct`` (to pair with
196 `~lsst.serialization.OutputArchive.serialize_direct`) because the
197 caller can just call a deserializer function directly on a sub-model
198 of its Pydantic tree.
199 """
200 raise NotImplementedError()
202 @abstractmethod
203 def get_frame_set(self, ref: P) -> FrameSet:
204 """Return an already-deserialized frame set from the archive.
206 Parameters
207 ----------
208 ref
209 Implementation-specific reference to the frame set.
211 Returns
212 -------
213 FrameSet
214 Loaded frame set.
215 """
216 raise NotImplementedError()
218 @abstractmethod
219 def get_array(
220 self,
221 model: ArrayReferenceModel | InlineArrayModel,
222 *,
223 slices: tuple[slice, ...] | EllipsisType = ...,
224 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
225 ) -> np.ndarray:
226 """Load an array from the archive.
228 Parameters
229 ----------
230 model
231 A Pydantic model that references or holds the array.
232 slices
233 Slices that specify a subset of the original array to read.
234 strip_header
235 A callable that strips out any FITS header cards added by the
236 ``update_header`` argument in the corresponding call to
237 `~lsst.images.serialization.OutputArchive.add_array`.
238 """
239 raise NotImplementedError()
241 @abstractmethod
242 def get_table(
243 self,
244 model: TableModel,
245 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
246 ) -> astropy.table.Table:
247 """Load a table from the archive.
249 Parameters
250 ----------
251 model
252 A Pydantic model that references or holds the table.
253 strip_header
254 A callable that strips out any FITS header cards added by the
255 ``update_header`` argument in the corresponding call to
256 `~lsst.serialization.OutputArchive.add_table`.
258 Returns
259 -------
260 astropy.table.Table
261 The loaded table.
262 """
263 raise NotImplementedError()
265 @abstractmethod
266 def get_structured_array(
267 self,
268 model: TableModel,
269 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
270 ) -> np.ndarray:
271 """Load a table from the archive as a structured array.
273 Parameters
274 ----------
275 model
276 A Pydantic model that references or holds the table.
277 strip_header
278 A callable that strips out any FITS header cards added by the
279 ``update_header`` argument in the corresponding call to
280 `~lsst.serialization.OutputArchive.add_structured_array`.
282 Returns
283 -------
284 numpy.ndarray
285 The loaded table as a structured array.
286 """
287 raise NotImplementedError()
289 def get_opaque_metadata(self) -> OpaqueArchiveMetadata | None:
290 """Return opaque metadata loaded from the file that should be saved if
291 another version of the object is saved to the same file format.
293 Returns
294 -------
295 OpaqueArchiveMetadata
296 Opaque metadata specific to this archive type that should be
297 round-tripped if it is saved in the same format.
298 """
299 return None
302class DetachedArchive(InputArchive[Any]):
303 """An input archive that is not attached to any file.
305 Every method that would read data from a file raises
306 `ArchiveAccessRequiredError`.
308 Notes
309 -----
310 Passing an instance to `ArchiveTree.deserialize_component` probes
311 whether a component can be deserialized from the tree alone: success
312 means no file access was needed, while `ArchiveAccessRequiredError`
313 means the caller must use a live archive instead. Instances hold no
314 state, so a single instance can be shared by any number of probes.
315 """
317 def deserialize_pointer[U: ArchiveTree, V](
318 self, pointer: Any, model_type: type[U], deserializer: Callable[[U, InputArchive[Any]], V]
319 ) -> V:
320 # Docstring inherited.
321 raise ArchiveAccessRequiredError("Dereferencing an archive pointer requires file access.")
323 def get_frame_set(self, ref: Any) -> FrameSet:
324 # Docstring inherited.
325 raise ArchiveAccessRequiredError("Reading a frame set requires file access.")
327 def get_array(
328 self,
329 model: ArrayReferenceModel | InlineArrayModel,
330 *,
331 slices: tuple[slice, ...] | EllipsisType = ...,
332 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
333 ) -> np.ndarray:
334 # Docstring inherited.
335 raise ArchiveAccessRequiredError("Reading an array requires file access.")
337 def get_table(
338 self,
339 model: TableModel,
340 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
341 ) -> astropy.table.Table:
342 # Docstring inherited.
343 raise ArchiveAccessRequiredError("Reading a table requires file access.")
345 def get_structured_array(
346 self,
347 model: TableModel,
348 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
349 ) -> np.ndarray:
350 # Docstring inherited.
351 raise ArchiveAccessRequiredError("Reading a structured array requires file access.")