Coverage for tests/test_serialization_backends.py: 97%
137 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -0700
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -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
14import gzip
15import io
16import os
17import sys
18from pathlib import Path
19from unittest import mock
21import numpy as np
22import pytest
24from lsst.images import Box, Image
25from lsst.images import fits as images_fits
26from lsst.images.serialization import Backend, backend_for_name, backend_for_path, backend_for_stream
27from lsst.images.serialization._backends import (
28 _decompress_path_to_temp_file,
29 _is_binary_stream,
30 _path_is_compressed,
31)
32from lsst.resources import ResourcePath
34try:
35 from compression import zstd as _stdlib_zstd # noqa: F401 -- detect zstd availability
37 ZSTD_AVAILABLE = True
38except ImportError:
39 try:
40 import zstandard # noqa: F401
42 ZSTD_AVAILABLE = True
43 except ImportError:
44 ZSTD_AVAILABLE = False
47def _zstd_compress(data: bytes) -> bytes:
48 """Compress with whichever zstd library is available."""
49 try:
50 from compression import zstd
51 except ImportError:
52 import zstandard
54 return zstandard.ZstdCompressor().compress(data)
55 return zstd.compress(data)
58def test_backend_for_path_fits() -> None:
59 """Verify that a .fits path resolves to the FITS backend."""
60 from lsst.images.fits import FitsInputArchive
62 b = backend_for_path("a/b/c.fits")
63 assert isinstance(b, Backend)
64 assert b.name == "fits"
65 assert b.input_archive is FitsInputArchive
66 assert callable(b.write)
69def test_backend_for_path_fits_gz() -> None:
70 """Verify that .fits.gz and URL-form .fits.gz both resolve to the
71 FITS backend.
72 """
73 assert backend_for_path("c.fits.gz").name == "fits"
74 assert backend_for_path("file://a/b/c.fits.gz?param=2").name == "fits"
77def test_backend_for_path_json() -> None:
78 """Verify that a .json path resolves to the JSON backend."""
79 from lsst.images.json import JsonInputArchive
81 b = backend_for_path("c.json")
82 assert b.name == "json"
83 assert b.input_archive is JsonInputArchive
86def test_backend_for_path_ndf() -> None:
87 """Verify that .sdf and .h5 paths both resolve to the NDF backend."""
88 assert backend_for_path("c.sdf").name == "ndf"
89 assert backend_for_path("c.h5").name == "ndf"
92def test_backend_for_path_unknown_raises() -> None:
93 """Verify that an unrecognised suffix raises ValueError naming known
94 formats.
95 """
96 with pytest.raises(ValueError) as exc_info:
97 backend_for_path("c.txt")
98 assert ".fits" in str(exc_info.value)
101def test_minify_unsupported_schema_uses_shared_dispatch(tmp_path: Path) -> None:
102 """Verify minify resolves backend and schema via the shared APIs.
104 Reaching the 'no subsetter' error proves backend_for_path and
105 get_basic_info ran and detected schema_name 'image'.
106 """
107 from lsst.images.tests._minify_for_fixtures import minify
109 src = os.path.join(tmp_path, "plain.fits")
110 out = os.path.join(tmp_path, "plain.json")
111 images_fits.write(Image(np.zeros((4, 4), dtype=np.float32), bbox=Box.factory[0:4, 0:4]), src)
112 with pytest.raises(NotImplementedError) as exc_info:
113 minify(src, out)
114 assert "image" in str(exc_info.value)
117def test_backend_for_name() -> None:
118 """Verify explicit format names resolve to their backends."""
119 assert backend_for_name("fits").name == "fits"
120 assert backend_for_name("ndf").name == "ndf"
121 assert backend_for_name("json").name == "json"
124def test_backend_for_name_unknown_raises() -> None:
125 """Verify an unknown format name raises ValueError naming it."""
126 with pytest.raises(ValueError) as exc_info:
127 backend_for_name("hdf")
128 assert "hdf" in str(exc_info.value)
131def test_backend_for_stream_fits_magic() -> None:
132 """Verify FITS magic bytes resolve to the FITS backend without consuming
133 the stream.
134 """
135 stream = io.BytesIO(b"SIMPLE = T / conforms")
136 assert backend_for_stream(stream).name == "fits"
137 # Sniffing must not consume the stream.
138 assert stream.tell() == 0
141def test_backend_for_stream_hdf5_magic() -> None:
142 """Verify the HDF5 signature resolves to the NDF backend."""
143 stream = io.BytesIO(b"\x89HDF\r\n\x1a\n" + b"\x00" * 8)
144 assert backend_for_stream(stream).name == "ndf"
147def test_backend_for_stream_json() -> None:
148 """Verify JSON content resolves to the JSON backend."""
149 assert backend_for_stream(io.BytesIO(b'{"schema_url": "x"}')).name == "json"
152def test_backend_for_stream_json_leading_whitespace() -> None:
153 """Verify JSON detection tolerates leading whitespace."""
154 assert backend_for_stream(io.BytesIO(b' \n\t {"a": 1}')).name == "json"
157def test_backend_for_stream_unknown_raises() -> None:
158 """Verify unrecognized content raises ValueError naming known formats."""
159 with pytest.raises(ValueError) as exc_info:
160 backend_for_stream(io.BytesIO(b"not any known format"))
161 msg = str(exc_info.value)
162 assert "FITS" in msg
163 assert "format" in msg
166def test_backend_for_stream_gzip_compressed_raises() -> None:
167 """Verify a gzip-compressed stream raises ValueError naming gzip."""
168 with pytest.raises(ValueError) as exc_info:
169 backend_for_stream(io.BytesIO(gzip.compress(b"SIMPLE = whatever")))
170 assert "gzip" in str(exc_info.value)
173def test_backend_for_stream_zstd_compressed_raises() -> None:
174 """Verify a zstd-compressed stream raises ValueError naming zstd."""
175 with pytest.raises(ValueError) as exc_info:
176 backend_for_stream(io.BytesIO(b"\x28\xb5\x2f\xfd" + b"frame"))
177 assert "zstd" in str(exc_info.value)
180def test_backend_for_path_strips_gz() -> None:
181 """Verify .gz compression suffixes are stripped before extension
182 dispatch.
183 """
184 assert backend_for_path("c.json.gz").name == "json"
185 assert backend_for_path("c.h5.gz").name == "ndf"
188def test_backend_for_path_strips_zst() -> None:
189 """Verify .zst compression suffixes are stripped before extension
190 dispatch.
191 """
192 assert backend_for_path("c.fits.zst").name == "fits"
193 assert backend_for_path("c.sdf.zst").name == "ndf"
196def test_backend_for_path_bare_compression_suffix_raises() -> None:
197 """Verify a bare compression suffix with no format extension raises
198 ValueError.
199 """
200 with pytest.raises(ValueError):
201 backend_for_path("c.gz")
204def test_is_binary_stream() -> None:
205 """Verify _is_binary_stream accepts streams and rejects path-like
206 inputs.
207 """
208 assert _is_binary_stream(io.BytesIO(b""))
209 assert not _is_binary_stream("a.fits")
210 # ResourcePath has read() but no seek(); it must not look like a
211 # stream.
212 assert not _is_binary_stream(ResourcePath("a.fits", forceAbsolute=False))
215def test_path_is_compressed() -> None:
216 """Verify _path_is_compressed recognizes .gz and .zst suffixes only."""
217 assert _path_is_compressed("a/b.fits.gz")
218 assert _path_is_compressed("a/b.json.zst")
219 assert not _path_is_compressed("a/b.fits")
222_DECOMPRESS_PAYLOAD = b"SIMPLE = fake fits payload " * 1000
225def test_decompress_path_gzip(tmp_path: Path) -> None:
226 """Verify a .gz path stream-decompresses into a real temporary file."""
227 path = tmp_path / "x.fits.gz"
228 path.write_bytes(gzip.compress(_DECOMPRESS_PAYLOAD))
229 with _decompress_path_to_temp_file(str(path)) as handle:
230 # The decompressed data lives in a real file, not in memory.
231 assert not isinstance(handle, io.BytesIO)
232 assert hasattr(handle, "fileno")
233 assert handle.read() == _DECOMPRESS_PAYLOAD
236@pytest.mark.skipif(not ZSTD_AVAILABLE, reason="no zstd decompressor available")
237def test_decompress_path_zstd(tmp_path: Path) -> None:
238 """Verify a .zst path decompresses through the available zstd library."""
239 path = tmp_path / "x.fits.zst"
240 path.write_bytes(_zstd_compress(_DECOMPRESS_PAYLOAD))
241 with _decompress_path_to_temp_file(str(path)) as handle:
242 assert handle.read() == _DECOMPRESS_PAYLOAD
245def test_decompress_suffix_without_compression_raises(tmp_path: Path) -> None:
246 """Verify a .gz name whose content is not gzip fails honestly."""
247 # The suffix selects the decompressor, so a .gz name whose content
248 # is not gzip fails honestly.
249 path = tmp_path / "x.fits.gz"
250 path.write_bytes(_DECOMPRESS_PAYLOAD)
251 with pytest.raises(gzip.BadGzipFile):
252 _decompress_path_to_temp_file(str(path))
255def test_decompress_zstd_no_decompressor(tmp_path: Path) -> None:
256 """Verify a helpful ValueError when no zstd decompressor can be
257 imported.
258 """
259 path = tmp_path / "x.fits.zst"
260 path.write_bytes(b"\x28\xb5\x2f\xfd" + b"pretend zstd frame")
261 # None entries make both import routes raise ImportError.
262 blocked = {"compression": None, "compression.zstd": None, "zstandard": None}
263 with mock.patch.dict(sys.modules, blocked):
264 with pytest.raises(ValueError) as exc_info:
265 _decompress_path_to_temp_file(str(path))
266 assert "zstd" in str(exc_info.value)