Coverage for python/lsst/images/serialization/_backends.py: 99%
103 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.
12from __future__ import annotations
14__all__ = ("Backend", "backend_for_name", "backend_for_path", "backend_for_stream")
16import contextlib
17import dataclasses
18import gzip
19import os
20import shutil
21import tempfile
22from collections.abc import Callable
23from typing import IO, TYPE_CHECKING, cast
25from lsst.resources import ResourcePath, ResourcePathExpression
27if TYPE_CHECKING:
28 from typing_extensions import TypeIs
30 from ._input_archive import InputArchive
32_GZIP_MAGIC = b"\x1f\x8b"
33"""Leading bytes of a gzip member (diagnostics only; compressed streams
34are the caller's responsibility to decompress)."""
36_ZSTD_MAGIC = b"\x28\xb5\x2f\xfd"
37"""Leading bytes of a zstd frame (diagnostics only; compressed streams
38are the caller's responsibility to decompress)."""
40_COMPRESSION_SUFFIXES = (".gz", ".zst")
41"""File name suffixes implying whole-file compression."""
43_FITS_MAGIC = b"SIMPLE ="
44"""Leading bytes of a standard FITS primary header."""
46_HDF5_MAGIC = b"\x89HDF\r\n\x1a\n"
47"""Leading bytes of an HDF5 file."""
50def _is_binary_stream(obj: object) -> TypeIs[IO[bytes]]:
51 """Return whether ``obj`` is a readable, seekable binary stream.
53 ``seek`` is the discriminator against `~lsst.resources.ResourcePath`,
54 which also has a ``read`` method.
55 """
56 return hasattr(obj, "read") and hasattr(obj, "seek")
59def _path_is_compressed(path: ResourcePathExpression) -> bool:
60 """Return whether ``path``'s file name ends in a compression suffix.
62 Parameters
63 ----------
64 path
65 Path to inspect; convertible to `lsst.resources.ResourcePath`.
66 """
67 return ResourcePath(path).basename().endswith(_COMPRESSION_SUFFIXES)
70def _open_zstd_stream(raw: IO[bytes]) -> IO[bytes]:
71 """Wrap ``raw`` in a streaming zstd decompressor, preferring the stdlib.
73 The imports are function-scoped optional-dependency guards:
74 ``compression.zstd`` exists only on Python >= 3.14 and ``zstandard``
75 is a third-party fallback that is not a required dependency.
77 Parameters
78 ----------
79 raw
80 Binary stream positioned at the start of a zstd frame.
82 Raises
83 ------
84 ValueError
85 If no zstd decompressor is available.
86 """
87 try:
88 from compression import zstd
89 except ImportError:
90 try:
91 import zstandard
92 except ImportError:
93 raise ValueError(
94 "Data is zstd-compressed, but no zstd decompressor is "
95 "available; install the 'zstandard' package or use "
96 "Python >= 3.14."
97 ) from None
98 return cast(IO[bytes], zstandard.ZstdDecompressor().stream_reader(raw))
99 return cast(IO[bytes], zstd.ZstdFile(raw))
102def _decompress_path_to_temp_file(path: ResourcePathExpression) -> IO[bytes]:
103 """Stream-decompress a compressed file into an anonymous temporary file.
105 The decompressor is selected from the file name's compression suffix.
106 A compressed file may be arbitrarily large, so its decompressed
107 content goes to disk in bounded-memory chunks rather than into memory.
109 Parameters
110 ----------
111 path
112 Path whose file name ends in a compression suffix (see
113 `_path_is_compressed`); convertible to
114 `lsst.resources.ResourcePath`.
116 Returns
117 -------
118 `typing.IO` [ `bytes` ]
119 Open, seekable binary handle positioned at the start of the
120 decompressed data. The file is deleted when the handle is
121 closed; the caller owns closing it.
123 Raises
124 ------
125 ValueError
126 If the path is zstd-compressed and no zstd decompressor is
127 available.
128 """
129 uri = ResourcePath(path)
130 with uri.open("rb") as raw:
131 if uri.basename().endswith(".gz"):
132 decompressor = cast(IO[bytes], gzip.GzipFile(fileobj=cast(IO[bytes], raw)))
133 else:
134 decompressor = _open_zstd_stream(cast(IO[bytes], raw))
135 with decompressor, contextlib.ExitStack() as stack:
136 temp = stack.enter_context(tempfile.TemporaryFile())
137 shutil.copyfileobj(decompressor, temp, 1024 * 1024)
138 temp.seek(0)
139 # Success: ownership of the temporary file transfers to the
140 # caller; the stack no longer closes it.
141 stack.pop_all()
142 return temp
145@dataclasses.dataclass(frozen=True)
146class Backend:
147 """A file-format backend resolved from a path suffix.
149 Bundles the backend's free ``write`` function and its `InputArchive`
150 subclass. Reading goes through the generic ``open`` / ``read`` in
151 `lsst.images.serialization`, which use the `InputArchive`'s
152 ``get_basic_info`` and ``open_tree``.
153 """
155 name: str
156 write: Callable[..., object]
157 input_archive: type[InputArchive]
160def _backend_for_format(name: str) -> Backend | None:
161 """Return the `Backend` registered under ``name``, or `None`.
163 Backends are imported lazily so optional dependencies (e.g. ``h5py``)
164 are only required when actually used.
165 """
166 match name:
167 case "fits":
168 from ..fits import FitsInputArchive
169 from ..fits import write as fits_write
171 return Backend("fits", fits_write, FitsInputArchive)
172 case "ndf":
173 from ..ndf import NdfInputArchive
174 from ..ndf import write as ndf_write
176 return Backend("ndf", ndf_write, NdfInputArchive)
177 case "json":
178 from ..json import JsonInputArchive
179 from ..json import write as json_write
181 return Backend("json", json_write, JsonInputArchive)
182 return None
185def backend_for_name(name: str) -> Backend:
186 """Return the `Backend` with the given format name.
188 Parameters
189 ----------
190 name
191 Backend format name: ``"fits"``, ``"ndf"``, or ``"json"``.
193 Raises
194 ------
195 ValueError
196 If ``name`` is not a recognized backend name.
197 """
198 backend = _backend_for_format(name)
199 if backend is None:
200 raise ValueError(f"Unrecognized format name: {name!r}; expected one of 'fits', 'ndf', 'json'.")
201 return backend
204def backend_for_path(path: ResourcePathExpression) -> Backend:
205 """Return the `Backend` for ``path`` based on its file extension.
207 Supported extensions: ``.fits`` (FITS), ``.h5`` / ``.sdf`` (NDF), and
208 ``.json`` (JSON), each optionally followed by a ``.gz`` or ``.zst``
209 compression suffix. The NDF and FITS backends are imported lazily so
210 optional dependencies (e.g. ``h5py``) are only required when actually
211 used.
213 Parameters
214 ----------
215 path
216 Path whose file extension selects the backend.
218 Raises
219 ------
220 ValueError
221 If the extension is not recognized.
222 """
223 uri = ResourcePath(path)
224 name = uri.basename()
225 for suffix in _COMPRESSION_SUFFIXES:
226 if name.endswith(suffix):
227 name = name.removesuffix(suffix)
228 break
229 match os.path.splitext(name)[1]:
230 case ".fits":
231 return backend_for_name("fits")
232 case ".h5" | ".sdf":
233 return backend_for_name("ndf")
234 case ".json":
235 return backend_for_name("json")
236 case ext:
237 raise ValueError(
238 f"Unrecognized file extension: {ext!r} from {uri!r}; "
239 "expected one of .fits, .h5, .sdf, .json, optionally with "
240 "a .gz or .zst compression suffix."
241 )
244def backend_for_stream(stream: IO[bytes]) -> Backend:
245 """Return the `Backend` for ``stream`` based on its leading bytes.
247 The stream is restored to the position it was passed in with.
248 Compressed content is not accepted: whoever produced the stream knows
249 how it was compressed and decompresses it before handing it over.
251 Parameters
252 ----------
253 stream
254 Seekable binary stream positioned at the start of the data.
256 Raises
257 ------
258 ValueError
259 If the leading bytes match no supported format, including when
260 they carry gzip or zstd compression magic.
261 """
262 start = stream.tell()
263 head = stream.read(512)
264 stream.seek(start)
265 if head.startswith(_FITS_MAGIC):
266 return backend_for_name("fits")
267 if head.startswith(_HDF5_MAGIC):
268 return backend_for_name("ndf")
269 if head.lstrip().startswith(b"{"):
270 return backend_for_name("json")
271 if head.startswith(_GZIP_MAGIC):
272 raise ValueError("The stream appears to be gzip-compressed; decompress it before reading.")
273 if head.startswith(_ZSTD_MAGIC):
274 raise ValueError("The stream appears to be zstd-compressed; decompress it before reading.")
275 raise ValueError(
276 f"Could not identify a supported format from the leading bytes "
277 f"{head[:16]!r}; expected FITS, HDF5/NDF, or JSON content. "
278 "Specify the format explicitly if it is known."
279 )