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

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.""" 

12 

13from __future__ import annotations 

14 

15__all__ = ("Reader", "open_archive") 

16 

17from collections.abc import Iterator 

18from contextlib import AbstractContextManager, contextmanager 

19from typing import IO, Any, TypeVar, overload 

20 

21from lsst.resources import ResourcePathExpression 

22 

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 

34 

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") 

40 

41 

42class Reader[T]: 

43 """A handle to an open ``lsst.images`` file. 

44 

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. 

50 

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 """ 

63 

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 

76 

77 def _check_open(self) -> None: 

78 if self._closed: 

79 raise RuntimeError("Reader is closed; use it only inside its 'with' block.") 

80 

81 @property 

82 def info(self) -> ArchiveInfo: 

83 """Schema name/version/url and format version for this file.""" 

84 return self._info 

85 

86 @property 

87 def metadata(self) -> dict[str, MetadataValue]: 

88 """Flexible metadata stored with the object.""" 

89 return self._tree.metadata 

90 

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 

95 

96 def get_tree(self) -> ArchiveTree: 

97 """Return the validated on-disk tree for advanced, low-level access. 

98 

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 

104 

105 def get_component(self, name: str, **kwargs: Any) -> Any: 

106 """Deserialize and return a single named component. 

107 

108 Raises `~lsst.images.serialization.InvalidComponentError` for an 

109 unknown component name. 

110 

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) 

121 

122 def read(self, **kwargs: Any) -> T: 

123 """Deserialize and return the whole object. 

124 

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 

140 

141 

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. 

170 

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. 

174 

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. 

181 

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). 

203 

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()