Coverage for python / lsst / images / fits / _input_archive.py: 30%

202 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-28 01:29 -0800

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 

12from __future__ import annotations 

13 

14__all__ = ( 

15 "FitsInputArchive", 

16 "FitsOpaqueMetadata", 

17 "read", 

18) 

19 

20import io 

21import os 

22from collections.abc import Callable, Iterator 

23from contextlib import contextmanager 

24from functools import cached_property 

25from types import EllipsisType 

26from typing import IO, Any, Self 

27 

28import astropy.io.fits 

29import astropy.table 

30import fsspec 

31import numpy as np 

32import pydantic 

33 

34from lsst.resources import ResourcePath, ResourcePathExpression 

35 

36from .._transforms import FrameSet 

37from ..serialization import ( 

38 ArchiveTree, 

39 ArrayReferenceModel, 

40 InputArchive, 

41 TableCellReferenceModel, 

42 TableModel, 

43 no_header_updates, 

44) 

45from ._common import ExtensionHDU, ExtensionKey, FitsOpaqueMetadata, InvalidFitsArchiveError 

46 

47 

48def read[T: Any]( 

49 cls: type[T], 

50 path: ResourcePathExpression, 

51 *, 

52 page_size: int = 2880 * 50, 

53 partial: bool | None = None, 

54 **kwargs: Any, 

55) -> T: 

56 """Read an object from a FITS file. 

57 

58 Parameters 

59 ---------- 

60 path 

61 File to read; convertible to `lsst.resources.ResourcePath`. 

62 page_size 

63 Minimum number of bytes to read at at once. Making this a multiple 

64 of the FITS block size (2880) is recommended. 

65 partial 

66 Whether we will be reading only some of the archive, or if memory 

67 pressure forces us to read it only a little at a time. If `False`, 

68 the entire raw file may be read into memory up front. Defaults to 

69 `True` if any extra ``**kwargs`` are passed with values other than 

70 `None`, since those usually indicate that only some of the original 

71 object will be loaded. 

72 **kwargs 

73 Extra keyword arguments passed to ``cls.deserialize``. 

74 

75 Returns 

76 ------- 

77 object 

78 The loaded object. 

79 

80 Notes 

81 ----- 

82 Supported types must implement ``deserialize`` and 

83 ``_get_archive_tree_type`` (see `.Image` for an example). 

84 """ 

85 if partial is None: 

86 partial = any(v is not None for v in kwargs.values()) 

87 with FitsInputArchive.open(path, page_size=page_size, partial=partial) as archive: 

88 tree = archive.get_tree(cls._get_archive_tree_type(TableCellReferenceModel)) 

89 obj = cls.deserialize(tree, archive, **kwargs) 

90 obj._opaque_metadata = archive.get_opaque_metadata() 

91 return obj 

92 

93 

94class FitsInputArchive(InputArchive[TableCellReferenceModel]): 

95 """An implementation of the `.serialization.InputArchive` interface that 

96 reads from FITS files. 

97 

98 Instances of this class should only be constructed via the `open` 

99 context manager. 

100 """ 

101 

102 def __init__(self, stream: IO[bytes]): 

103 self._primary_hdu = astropy.io.fits.PrimaryHDU.readfrom(stream) 

104 # TODO: read and strip subformat declaration and version, once we start 

105 # writing those. 

106 # 

107 # TODO: do some basic checks that the file format conforms to our 

108 # expectations (e.g. primary HDU should have no data). 

109 # 

110 # Read and strip the addresses and sizes from the headers. We don't 

111 # actually need the index address because we always want to read the 

112 # JSON HDU, too, and the index HDU is always the next one (but this 

113 # could change in the future). 

114 json_address: int = self._primary_hdu.header.pop("JSONADDR") 

115 json_size: int = self._primary_hdu.header.pop("JSONSIZE") 

116 del self._primary_hdu.header["INDXADDR"] 

117 index_size: int = self._primary_hdu.header.pop("INDXSIZE") 

118 # Save the remaining primary header keys so we can propagate them on 

119 # rewrite. 

120 self._opaque_metadata = FitsOpaqueMetadata() 

121 self._opaque_metadata.headers[ExtensionKey()] = self._primary_hdu.header.copy(strip=True) 

122 # Read the JSON and index HDUs from the end. 

123 stream.seek(json_address) 

124 tail_data = stream.read(json_size + index_size) 

125 index_hdu = astropy.io.fits.BinTableHDU.fromstring(tail_data[json_size:]) 

126 # Initialize lazy readers for all of the regular HDUs and the JSON HDU. 

127 self._readers = { 

128 ExtensionKey.from_index_row(row): _ExtensionReader.from_index_row(row, stream) 

129 for row in index_hdu.data 

130 } 

131 self._readers[ExtensionKey("JSON")] = _ExtensionReader.from_bytes( 

132 astropy.io.fits.BinTableHDU, tail_data[:json_size] 

133 ) 

134 # Make any empty dictionary to cache deserialized objects. 

135 self._deserialized_pointer_cache: dict[TableCellReferenceModel, Any] = {} 

136 

137 @classmethod 

138 @contextmanager 

139 def open( 

140 cls, 

141 path: ResourcePathExpression, 

142 *, 

143 page_size: int = 2880 * 50, 

144 partial: bool = False, 

145 ) -> Iterator[Self]: 

146 """Create an output archive that writes to the given file. 

147 

148 Parameters 

149 ---------- 

150 path 

151 File to read; convertible to `lsst.resources.ResourcePath`. 

152 page_size 

153 Minimum number of bytes to read at at once. Making this a multiple 

154 of the FITS block size (2880) is recommended. 

155 partial 

156 Whether we will be reading only some of the archive, or if memory 

157 pressure forces us to read it only a little at a time. If `False` 

158 (default), the entire raw file may be read into memory up front. 

159 

160 Returns 

161 ------- 

162 `contextlib.AbstractContextManager` [`FitsInputArchive`] 

163 A context manager that returns a `FitsInputArchive` when entered. 

164 """ 

165 path = ResourcePath(path) 

166 stream: IO[bytes] 

167 if not partial: 

168 stream = io.BytesIO(path.read()) 

169 yield cls(stream) 

170 else: 

171 fs: fsspec.AbstractFileSystem 

172 fs, fp = path.to_fsspec() 

173 with fs.open(fp, block_size=page_size) as stream: 

174 yield cls(stream) 

175 

176 def get_tree[T: ArchiveTree](self, model_type: type[T]) -> T: 

177 """Read the JSON tree from the archive. 

178 

179 Parameters 

180 ---------- 

181 model_type 

182 A Pydantic model type to use to validate the JSON. 

183 

184 Returns 

185 ------- 

186 T 

187 The validated Pydantic model. 

188 """ 

189 json_bytes = self._readers[ExtensionKey("JSON")].data[0]["JSON"].tobytes() 

190 return model_type.model_validate_json(json_bytes) 

191 

192 def deserialize_pointer[U: pydantic.BaseModel, V]( 

193 self, 

194 pointer: TableCellReferenceModel, 

195 model_type: type[U], 

196 deserializer: Callable[[U, InputArchive[TableCellReferenceModel]], V], 

197 ) -> V: 

198 # Docstring inherited. 

199 if (cached := self._deserialized_pointer_cache.get(pointer)) is not None: 

200 return cached 

201 _, reader = self._get_source_reader(pointer) 

202 try: 

203 json_bytes = reader.data[pointer.row][pointer.column].tobytes() 

204 except Exception as err: 

205 raise InvalidFitsArchiveError( 

206 f"Failed to access the table cell referenced by {pointer.model_dump_json()}." 

207 ) from err 

208 result = deserializer(model_type.model_validate_json(json_bytes), self) 

209 self._deserialized_pointer_cache[pointer] = result 

210 return result 

211 

212 def get_frame_set(self, ref: TableCellReferenceModel) -> FrameSet: 

213 try: 

214 result = self._deserialized_pointer_cache[ref] 

215 except KeyError: 

216 raise AssertionError( 

217 f"Frame set at {ref.model_dump_json(indent=2)} must be deserialized " 

218 "before any dependent transform can be." 

219 ) from None 

220 if not isinstance(result, FrameSet): 

221 raise InvalidFitsArchiveError(f"Expected a FrameSet instance at {ref.model_dump_json(indent=2)}.") 

222 return result 

223 

224 def get_array( 

225 self, 

226 ref: ArrayReferenceModel, 

227 *, 

228 slices: tuple[slice, ...] | EllipsisType = ..., 

229 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

230 ) -> np.ndarray: 

231 key, reader = self._get_source_reader(ref) 

232 if slices is not ...: 

233 array = reader.section[slices] 

234 else: 

235 array = reader.data 

236 if key not in self._opaque_metadata.headers: 

237 opaque_header = reader.header.copy(strip=True) 

238 strip_header(opaque_header) 

239 self._opaque_metadata.add_header(opaque_header, key=key) 

240 return array 

241 

242 def get_table( 

243 self, 

244 ref: TableModel, 

245 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

246 ) -> astropy.table.Table: 

247 # Docstring inherited. 

248 array = self.get_structured_array(ref, strip_header) 

249 table = astropy.table.Table(array) 

250 for c in ref.columns: 

251 c.update_table(table) 

252 return table 

253 

254 def get_structured_array( 

255 self, 

256 ref: TableModel, 

257 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

258 ) -> np.ndarray: 

259 # Docstring inherited. 

260 key, reader = self._get_source_reader(ref) 

261 if key not in self._opaque_metadata.headers: 

262 opaque_header = reader.header.copy(strip=True) 

263 strip_header(opaque_header) 

264 self._opaque_metadata.add_header(opaque_header, key=key) 

265 return reader.hdu.data 

266 

267 def get_opaque_metadata(self) -> FitsOpaqueMetadata: 

268 # Docstring inherited. 

269 return self._opaque_metadata 

270 

271 def _get_source_reader( 

272 self, ref: ArrayReferenceModel | TableCellReferenceModel | TableModel 

273 ) -> tuple[ExtensionKey, _ExtensionReader]: 

274 """Get a reader for the extension referenced by a model. 

275 

276 Parameters 

277 ---------- 

278 ref 

279 A Pydantic model with a 'source' field that references a FITS HDU. 

280 

281 Returns 

282 ------- 

283 key 

284 Identifier pair for the HDU (EXTNAME, EXTVER). 

285 reader 

286 A reader object for the extension. 

287 """ 

288 if not isinstance(ref.source, str): 

289 raise InvalidFitsArchiveError(f"Reference with source={ref.source!r} is not a string.") 

290 if not ref.source.startswith("fits:"): 

291 raise InvalidFitsArchiveError( 

292 f"Reference with source={ref.source!r} does not start with 'fits:'." 

293 ) 

294 key = ExtensionKey.from_str(ref.source) 

295 try: 

296 reader = self._readers[key] 

297 except KeyError: 

298 raise InvalidFitsArchiveError(f"Unrecognized source value {key}.") from None 

299 if ref.source_is_table and not reader.is_table: 

300 raise InvalidFitsArchiveError( 

301 f"Extension with source={key} was expected to be be a binary table, not an image." 

302 ) 

303 elif not ref.source_is_table and reader.is_table: 

304 raise InvalidFitsArchiveError( 

305 f"Extension with source={key} was expected to be be an image, not a binary table." 

306 ) 

307 return key, reader 

308 

309 

310class _ExtensionReader: 

311 """A lazy-load reader for a single extension HDU. 

312 

313 Parameters 

314 ---------- 

315 hdu_cls 

316 The type of the astropy HDU instance to construct. 

317 stream 

318 The file-like object to read from. 

319 """ 

320 

321 def __init__(self, hdu_cls: type[ExtensionHDU], stream: IO[bytes]): 

322 self._hdu_cls = hdu_cls 

323 self._stream = stream 

324 

325 @classmethod 

326 def from_index_row(cls, index_row: np.void, stream: IO[bytes]) -> _ExtensionReader: 

327 """Construct from a row of the binary table index HDU. 

328 

329 Parameters 

330 ---------- 

331 index_row 

332 A record array row from the index HDU. 

333 stream 

334 The file-like object being used to read the full FITS file. 

335 

336 Returns 

337 ------- 

338 reader 

339 A reader object for the extension. 

340 """ 

341 match index_row["XTENSION"].strip(): 

342 case "IMAGE": 

343 hdu_cls = astropy.io.fits.ImageHDU 

344 case "BINTABLE": 

345 if index_row["ZIMAGE"]: 

346 hdu_cls = astropy.io.fits.CompImageHDU 

347 else: 

348 hdu_cls = astropy.io.fits.BinTableHDU 

349 case other: 

350 raise AssertionError(f"Unsupported HDU type {other!r}.") 

351 return _ExtensionReader( 

352 hdu_cls, 

353 _RangeStreamProxy( 

354 stream, 

355 start=int(index_row["HDRADDR"]), 

356 ), 

357 ) 

358 

359 @classmethod 

360 def from_bytes(cls, hdu_cls: type[ExtensionHDU], data: bytes) -> _ExtensionReader: 

361 """Construct from already-read `bytes` 

362 

363 Parameters 

364 ---------- 

365 hdu_cls 

366 The HDU type to instantiate. 

367 data 

368 Raw data for the HDU. 

369 

370 Returns 

371 ------- 

372 reader 

373 A reader object for extension. 

374 """ 

375 return _ExtensionReader(hdu_cls, io.BytesIO(data)) 

376 

377 @property 

378 def is_table(self) -> bool: 

379 """Whether this is logically a table HDU. 

380 

381 This is `False` for compressed image HDUs, even though they are 

382 represented in FITS as a binary table. 

383 """ 

384 return issubclass(self._hdu_cls, astropy.io.fits.BinTableHDU) and not issubclass( 

385 self._hdu_cls, astropy.io.fits.CompImageHDU 

386 ) 

387 

388 @cached_property 

389 def hdu(self) -> ExtensionHDU: 

390 """The Astropy HDU object.""" 

391 self._stream.seek(0) 

392 if self._hdu_cls is astropy.io.fits.CompImageHDU: 

393 # CompImageHDU.readfrom doesn't work; we need to make a minimal 

394 # example and report it upstream. Happily this workaround does 

395 # work. 

396 bintable_hdu = astropy.io.fits.BinTableHDU.readfrom(self._stream, memmap=False, cache=False) 

397 return self._hdu_cls(bintable=bintable_hdu) 

398 else: 

399 return self._hdu_cls.readfrom(self._stream, memmap=False, cache=False) 

400 

401 @property 

402 def header(self) -> astropy.io.fits.Header: 

403 """The header of the HDU.""" 

404 return self.hdu.header 

405 

406 @property 

407 def data(self) -> np.ndarray: 

408 """The data for the HDU.""" 

409 return self.hdu.data 

410 

411 @property 

412 def section(self) -> astropy.io.fits.Section | astropy.io.fits.CompImageSection: 

413 """An Astropy expression object that reads a subset of the data when 

414 sliced. 

415 """ 

416 return self.hdu.section 

417 

418 

419class _RangeStreamProxy(IO[bytes]): 

420 """A readable IO proxy object that makes the beginning of the file appear 

421 at a custom position. 

422 

423 Parameters 

424 ---------- 

425 base 

426 Underlying readable, seekable buffer to proxy. 

427 start 

428 Offset into the base stream that will be considered the start of the 

429 proxy stream. 

430 

431 Notes 

432 ----- 

433 This class exists because Astropy doesn't seem to provide a way to read a 

434 single HDU that starts at the current seek position of a file-like object. 

435 It does provide ``readfrom`` methods on its HDU objects that take a 

436 file-like object, but these assume (possibly unintentionally; it only 

437 happens when Astropy is trying to see whether the file was opened for 

438 appending) that ``seek(0)`` will set the file-like object to the start of 

439 the HDU. 

440 """ 

441 

442 def __init__(self, base: IO[bytes], start: int): 

443 self._base = base 

444 self._start = start 

445 

446 @property 

447 def mode(self) -> str: 

448 return "rb" 

449 

450 def __enter__(self) -> Self: 

451 raise AssertionError("This proxy should not be used as a context manager.") 

452 

453 def __exit__(self, type: Any, value: Any, traceback: Any) -> None: 

454 raise AssertionError("This proxy should not be used as a context manager.") 

455 

456 def __iter__(self) -> Iterator[bytes]: 

457 return self._base.__iter__() 

458 

459 def __next__(self) -> bytes: 

460 return self._base.__next__() 

461 

462 def close(self) -> None: 

463 raise AssertionError("This proxy should not ever be closed.") 

464 

465 @property 

466 def closed(self) -> bool: 

467 return False 

468 

469 def fileno(self) -> int: 

470 raise OSError() 

471 

472 def flush(self) -> None: 

473 pass 

474 

475 def isatty(self) -> bool: 

476 return False 

477 

478 def read(self, n: int = -1, /) -> bytes: 

479 result = self._base.read(n) 

480 return result 

481 

482 def readable(self) -> bool: 

483 return True 

484 

485 def readline(self, limit: int = -1, /) -> bytes: 

486 return self._base.readline(limit) 

487 

488 def readlines(self, hint: int = -1, /) -> list[bytes]: 

489 return self._base.readlines(hint) 

490 

491 def seek(self, offset: int, whence: int = 0) -> int: 

492 match whence: 

493 case os.SEEK_SET: 

494 return self._base.seek(offset + self._start, os.SEEK_SET) - self._start 

495 case os.SEEK_CUR: 

496 return self._base.seek(offset, os.SEEK_CUR) - self._start 

497 case os.SEEK_END: 

498 return self._base.seek(offset, os.SEEK_END) - self._start 

499 raise TypeError(f"Invalid value for 'whence': {whence}.") 

500 

501 def seekable(self) -> bool: 

502 return True 

503 

504 def tell(self) -> int: 

505 return self._base.tell() - self._start 

506 

507 def truncate(self, size: int | None = None, /) -> int: 

508 raise OSError() 

509 

510 def writable(self) -> bool: 

511 return False 

512 

513 def write(self, arg: Any, /) -> int: 

514 raise OSError() 

515 

516 def writelines(self, arg: Any, /) -> None: 

517 raise OSError()