Coverage for python/lsst/images/serialization/_reader.py: 97%
71 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 02:27 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 02:27 -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"""User-facing ``open_archive`` for incremental, component-wise reads."""
13from __future__ import annotations
15__all__ = ("Reader", "open_archive")
17from collections.abc import Iterator
18from contextlib import AbstractContextManager, contextmanager
19from typing import IO, Any, TypeVar, overload
21from lsst.resources import ResourcePathExpression
23from ._backends import (
24 _decompress_path_to_temp_file,
25 _is_binary_stream,
26 _path_is_compressed,
27 backend_for_name,
28 backend_for_path,
29 backend_for_stream,
30)
31from ._common import ArchiveTree, ButlerInfo, MetadataValue
32from ._input_archive import ArchiveInfo, InputArchive
33from ._io import public_type_for_schema
35# This pre-python-3.12 declaration is needed so Sphinx (the
36# autodoc-typehints plugin) can resolve the ``T`` forward reference in the
37# stringized annotations; the PEP 695 ``[T]`` parameters below are scoped to
38# their class/function and are not visible in the module globals.
39T = TypeVar("T")
42class Reader[T]:
43 """A handle to an open ``lsst.images`` file.
45 Returned by `open_archive`.
46 Lets the caller pull individual components, or the whole object, out of a
47 file that is opened once; the underlying archive caches dereferenced
48 pointers so repeated reads share work.
49 Valid only inside the ``with`` block that produced it.
51 Parameters
52 ----------
53 archive
54 Input archive backing the open file.
55 tree
56 Validated on-disk deserialization tree for the file.
57 info
58 Schema name, version, URL, and format version for the file.
59 expected_cls
60 Expected type of the deserialized object, or `None` to accept any
61 type.
62 """
64 def __init__(
65 self,
66 archive: InputArchive[Any],
67 tree: ArchiveTree,
68 info: ArchiveInfo,
69 expected_cls: type[T] | None,
70 ) -> None:
71 self._archive = archive
72 self._tree = tree
73 self._info = info
74 self._expected_cls = expected_cls
75 self._closed = False
77 def _check_open(self) -> None:
78 if self._closed:
79 raise RuntimeError("Reader is closed; use it only inside its 'with' block.")
81 @property
82 def info(self) -> ArchiveInfo:
83 """Schema name/version/url and format version for this file."""
84 return self._info
86 @property
87 def metadata(self) -> dict[str, MetadataValue]:
88 """Flexible metadata stored with the object."""
89 return self._tree.metadata
91 @property
92 def butler_info(self) -> ButlerInfo | None:
93 """Butler dataset info stored with the object, or `None`."""
94 return self._tree.butler_info
96 def get_tree(self) -> ArchiveTree:
97 """Return the validated on-disk tree for advanced, low-level access.
99 Most callers want `read` or `get_component` instead; the tree is the
100 raw deserialization model that those methods build on.
101 """
102 self._check_open()
103 return self._tree
105 def get_component(self, name: str, **kwargs: Any) -> Any:
106 """Deserialize and return a single named component.
108 Raises `~lsst.images.serialization.InvalidComponentError` for an
109 unknown component name.
111 Parameters
112 ----------
113 name
114 Name of the component to deserialize.
115 **kwargs
116 Additional keyword arguments forwarded to the component
117 deserializer.
118 """
119 self._check_open()
120 return self._tree.deserialize_component(name, self._archive, **kwargs)
122 def read(self, **kwargs: Any) -> T:
123 """Deserialize and return the whole object.
125 Parameters
126 ----------
127 **kwargs
128 Additional keyword arguments forwarded to the deserializer.
129 """
130 self._check_open()
131 obj = self._tree.deserialize(self._archive, **kwargs)
132 if hasattr(obj, "_opaque_metadata"):
133 obj._opaque_metadata = self._archive.get_opaque_metadata()
134 if self._expected_cls is not None and not isinstance(obj, self._expected_cls): 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true
135 raise TypeError(
136 f"{self._info.schema_name!r} deserialized to {type(obj).__name__}, "
137 f"not the requested {self._expected_cls.__name__}."
138 )
139 return obj
142@overload
143def open_archive[T](
144 path: ResourcePathExpression | IO[bytes],
145 cls: type[T],
146 *,
147 format: str | None = ...,
148 partial: bool = ...,
149 **backend_kwargs: Any,
150) -> AbstractContextManager[Reader[T]]: ...
151@overload
152def open_archive( 152 ↛ exitline 152 didn't return from function 'open_archive' because
153 path: ResourcePathExpression | IO[bytes],
154 cls: None = ...,
155 *,
156 format: str | None = ...,
157 partial: bool = ...,
158 **backend_kwargs: Any,
159) -> AbstractContextManager[Reader[Any]]: ...
160@contextmanager
161def open_archive(
162 path: ResourcePathExpression | IO[bytes],
163 cls: type[Any] | None = None,
164 *,
165 format: str | None = None,
166 partial: bool = True,
167 **backend_kwargs: Any,
168) -> Iterator[Reader]:
169 """Open an ``lsst.images`` file for incremental, component-wise reads.
171 Dispatches to the appropriate backend by file extension (or, for stream
172 input, by the leading bytes), resolves the registered in-memory type
173 from the file's schema, and returns a `Reader` context manager.
175 A path with a ``.gz``/``.zst`` compression suffix is stream-decompressed
176 into an anonymous temporary file first (in bounded-memory chunks, since
177 compressed data has no random access; ``partial`` is ignored for it).
178 zstd requires Python >= 3.14 or the ``zstandard`` package.
179 Stream input must already be decompressed: whoever produced the stream
180 knows how it was compressed.
182 Parameters
183 ----------
184 path
185 File to read; convertible to `lsst.resources.ResourcePath`, or a
186 seekable binary stream containing the file's content.
187 cls
188 Optional expected in-memory type.
189 When given, `open_archive` validates that the file's schema
190 resolves to a subclass of ``cls`` (raising `TypeError` otherwise)
191 and the returned `Reader` is typed accordingly, so `Reader.read`
192 needs no cast.
193 format
194 Optional backend name (``"fits"``, ``"ndf"``, or ``"json"``)
195 forcing the backend, instead of dispatching on the path's extension
196 or the stream's leading bytes.
197 partial
198 Forwarded to the backend ``open_tree``; defaults to `True` (a reader
199 is for incremental access).
200 A no-op for the JSON and NDF backends and for stream input.
201 **backend_kwargs
202 Backend-specific open options (e.g. ``page_size`` for FITS).
204 Raises
205 ------
206 ValueError
207 If the file extension or the stream's leading bytes are not
208 recognized, or ``format`` is not a known backend name.
209 ArchiveReadError
210 If the file's schema is not registered.
211 TypeError
212 If ``cls`` is given and the file's schema resolves to an
213 incompatible type.
214 """
215 source: ResourcePathExpression | IO[bytes]
216 temp_file: IO[bytes] | None = None
217 if _is_binary_stream(path):
218 source = path
219 backend = backend_for_name(format) if format is not None else backend_for_stream(path)
220 else:
221 backend = backend_for_name(format) if format is not None else backend_for_path(path)
222 if _path_is_compressed(path):
223 temp_file = _decompress_path_to_temp_file(path)
224 source = temp_file
225 else:
226 source = path
227 try:
228 with backend.input_archive.open_tree(source, partial=partial, **backend_kwargs) as (
229 archive,
230 tree,
231 info,
232 ):
233 if cls is not None:
234 resolved = public_type_for_schema(info.schema_name)
235 if resolved is not None and not issubclass(resolved, cls):
236 raise TypeError(
237 f"{path!r} has schema {info.schema_name!r} (type {resolved.__name__}), "
238 f"which is not a {cls.__name__}."
239 )
240 reader: Reader[Any] = Reader(archive, tree, info, cls)
241 try:
242 yield reader
243 finally:
244 reader._closed = True
245 finally:
246 if temp_file is not None:
247 temp_file.close()