Coverage for tests/test_cli.py: 84%

217 statements  

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

11from __future__ import annotations 

12 

13import os 

14import subprocess 

15import sys 

16from pathlib import Path 

17from unittest import mock 

18 

19import astropy.io.fits 

20import click 

21import numpy as np 

22import pytest 

23from click.testing import CliRunner 

24 

25from lsst.images import Box, Image 

26from lsst.images import fits as images_fits 

27from lsst.images import json as images_json 

28from lsst.images.cli import main 

29from lsst.images.serialization import backend_for_path, read_archive 

30 

31 

32@pytest.fixture(scope="session") 

33def external_data_dir() -> str: 

34 """Return the external test-data directory path, skipping if unset.""" 

35 if (result := os.environ.get("TESTDATA_IMAGES_DIR")) is None: 35 ↛ 37line 35 didn't jump to line 37 because the condition on line 35 was always true

36 pytest.skip("TESTDATA_IMAGES_DIR is not set.") 

37 return result 

38 

39 

40def _make_cli_input(tmp_path: Path) -> str: 

41 """Return the path to a minimal FITS file written under tmp_path.""" 

42 path = str(tmp_path / "in.fits") 

43 astropy.io.fits.PrimaryHDU().writeto(path) 

44 return path 

45 

46 

47def _make_detect_file(tmp_path: Path, dataset_type: str | None) -> str: 

48 """Return a path to a FITS file with LSST BUTLER DATASETTYPE set to 

49 dataset_type. 

50 """ 

51 name = dataset_type.replace(" ", "_") if dataset_type is not None else "none" 

52 path = str(tmp_path / f"detect_{name}.fits") 

53 hdu = astropy.io.fits.PrimaryHDU() 

54 with images_fits.suppress_fits_card_warnings(): 

55 if dataset_type is not None: 

56 hdu.header["LSST BUTLER DATASETTYPE"] = dataset_type 

57 hdu.writeto(path) 

58 return path 

59 

60 

61def test_group_help() -> None: 

62 """Test that the root CLI group loads and lists core subcommands.""" 

63 result = CliRunner().invoke(main, ["--help"]) 

64 assert result.exit_code == 0, result.output 

65 assert "convert" in result.output 

66 assert "inspect" in result.output 

67 

68 

69def test_python_m_entry_point() -> None: 

70 """Test that python -m lsst.images runs the same CLI group.""" 

71 result = subprocess.run( 

72 [sys.executable, "-m", "lsst.images", "--help"], 

73 capture_output=True, 

74 text=True, 

75 ) 

76 assert result.returncode == 0, result.stderr 

77 assert "convert" in result.stdout 

78 assert "inspect" in result.stdout 

79 

80 

81def test_inspect_fits(tmp_path: Path) -> None: 

82 """Test 'inspect' on a FITS file.""" 

83 path = str(tmp_path / "x.fits") 

84 image = Image(np.zeros((4, 4), dtype=np.float32), bbox=Box.factory[0:4, 0:4]) 

85 images_fits.write(image, path) 

86 result = CliRunner().invoke(main, ["inspect", path]) 

87 assert result.exit_code == 0, result.output 

88 assert "https://images.lsst.io/schemas/image-1.0.0" in result.output 

89 assert "format version: 1" in result.output 

90 assert "python class:" in result.output 

91 assert "lsst.images.Image" in result.output 

92 

93 

94def test_inspect_json(tmp_path: Path) -> None: 

95 """Test 'inspect' on a JSON file.""" 

96 path = str(tmp_path / "x.json") 

97 image = Image(np.zeros((4, 4), dtype=np.float32), bbox=Box.factory[0:4, 0:4]) 

98 images_json.write(image, path) 

99 result = CliRunner().invoke(main, ["inspect", path]) 

100 assert result.exit_code == 0, result.output 

101 assert "image-1.0.0" in result.output 

102 assert "n/a" in result.output 

103 assert "python class:" in result.output 

104 assert "lsst.images.Image" in result.output 

105 

106 

107def test_inspect_unregistered_schema(tmp_path: Path) -> None: 

108 """Test that 'inspect' succeeds and reports an unregistered schema name.""" 

109 path = str(tmp_path / "fake.json") 

110 with open(path, "w") as f: 

111 f.write( 

112 '{"schema_url": "https://images.lsst.io/schemas/no-such-schema-99.0.0",' 

113 ' "schema_version": "99.0.0", "min_read_version": 1, "indirect": []}' 

114 ) 

115 result = CliRunner().invoke(main, ["inspect", path]) 

116 assert result.exit_code == 0, result.output 

117 assert "python class:" in result.output 

118 assert "<unregistered: no-such-schema>" in result.output 

119 

120 

121def test_inspect_unknown_extension(tmp_path: Path) -> None: 

122 """Test that 'inspect' fails with a non-zero exit code for an unsupported 

123 file extension. 

124 """ 

125 path = str(tmp_path / "x.txt") 

126 with open(path, "w") as stream: 

127 stream.write("nope") 

128 result = CliRunner().invoke(main, ["inspect", path]) 

129 assert result.exit_code != 0 

130 assert ".fits" in result.output 

131 

132 

133def test_reformat_round_trip_json_fits_json(tmp_path: Path) -> None: 

134 """Test that reformat JSON→FITS→JSON preserves the image data.""" 

135 image = Image(np.arange(16, dtype=np.float32).reshape(4, 4), bbox=Box.factory[0:4, 0:4]) 

136 src = str(tmp_path / "in.json") 

137 mid = str(tmp_path / "mid.fits") 

138 out = str(tmp_path / "out.json") 

139 images_json.write(image, src) 

140 

141 result = CliRunner().invoke(main, ["reformat", src, mid]) 

142 assert result.exit_code == 0, result.output 

143 assert backend_for_path(mid).input_archive.get_basic_info(mid).schema_name == "image" 

144 

145 result = CliRunner().invoke(main, ["reformat", mid, out]) 

146 assert result.exit_code == 0, result.output 

147 

148 np.testing.assert_array_equal(read_archive(out, Image).array, image.array) 

149 

150 

151def test_reformat_refuses_existing_output(tmp_path: Path) -> None: 

152 """Test that reformat refuses to overwrite an existing output file without 

153 --overwrite. 

154 """ 

155 image = Image(np.arange(16, dtype=np.float32).reshape(4, 4), bbox=Box.factory[0:4, 0:4]) 

156 src = str(tmp_path / "in.json") 

157 out = str(tmp_path / "out.fits") 

158 images_json.write(image, src) 

159 images_fits.write(image, out) 

160 result = CliRunner().invoke(main, ["reformat", src, out]) 

161 assert result.exit_code != 0 

162 assert "--overwrite" in result.output 

163 

164 

165def test_reformat_unknown_output_extension(tmp_path: Path) -> None: 

166 """Test that reformat fails for an unsupported output file extension.""" 

167 image = Image(np.arange(16, dtype=np.float32).reshape(4, 4), bbox=Box.factory[0:4, 0:4]) 

168 src = str(tmp_path / "in.json") 

169 images_json.write(image, src) 

170 result = CliRunner().invoke(main, ["reformat", src, str(tmp_path / "out.txt")]) 

171 assert result.exit_code != 0 

172 assert ".fits" in result.output 

173 

174 

175def test_detect_visit_image(tmp_path: Path) -> None: 

176 """Test that detect_legacy_type identifies visit_image and 

177 preliminary_visit_image. 

178 """ 

179 from lsst.images.cli._convert import detect_legacy_type 

180 

181 assert detect_legacy_type(_make_detect_file(tmp_path, "visit_image")) == "visit_image" 

182 assert detect_legacy_type(_make_detect_file(tmp_path, "preliminary_visit_image")) == "visit_image" 

183 

184 

185def test_detect_cell_coadd(tmp_path: Path) -> None: 

186 """Test that detect_legacy_type identifies deep_coadd_cell_predetection as 

187 cell_coadd. 

188 """ 

189 from lsst.images.cli._convert import detect_legacy_type 

190 

191 assert detect_legacy_type(_make_detect_file(tmp_path, "deep_coadd_cell_predetection")) == "cell_coadd" 

192 

193 

194def test_detect_indeterminate(tmp_path: Path) -> None: 

195 """Test that detect_legacy_type returns None for unknown or absent dataset- 

196 type headers. 

197 """ 

198 from lsst.images.cli._convert import detect_legacy_type 

199 

200 assert detect_legacy_type(_make_detect_file(tmp_path, None)) is None 

201 assert detect_legacy_type(_make_detect_file(tmp_path, "camera")) is None 

202 

203 

204def test_detect_visit_image_fixture(tmp_path: Path, external_data_dir: str) -> None: 

205 """Test that detect_legacy_type detects a real legacy visit-image fixture 

206 file. 

207 """ 

208 from lsst.images.cli._convert import detect_legacy_type 

209 

210 path = os.path.join(external_data_dir, "dp2", "legacy", "visit_image.fits") 

211 assert detect_legacy_type(path) == "visit_image" 

212 

213 

214def test_convert_visit_image_to_json(tmp_path: Path, external_data_dir: str) -> None: 

215 """Test that convert produces a valid visit_image JSON file from a legacy 

216 FITS fixture. 

217 """ 

218 pytest.importorskip("lsst.afw.image") 

219 src = os.path.join(external_data_dir, "dp2", "legacy", "visit_image.fits") 

220 out = str(tmp_path / "converted.json") 

221 result = CliRunner().invoke(main, ["convert", src, out]) 

222 assert result.exit_code == 0, result.output 

223 info = backend_for_path(out).input_archive.get_basic_info(out) 

224 assert info.schema_name == "visit_image" 

225 

226 

227def test_convert_refuses_existing_output(tmp_path: Path, external_data_dir: str) -> None: 

228 """Test that convert refuses to overwrite an existing output file without 

229 --overwrite. 

230 """ 

231 pytest.importorskip("lsst.afw.image") 

232 src = os.path.join(external_data_dir, "dp2", "legacy", "visit_image.fits") 

233 out = str(tmp_path / "exists.json") 

234 with open(out, "w") as stream: 

235 stream.write("{}") 

236 result = CliRunner().invoke(main, ["convert", src, out]) 

237 assert result.exit_code != 0 

238 assert "--overwrite" in result.output 

239 

240 

241def test_convert_cell_coadd_to_json(tmp_path: Path, external_data_dir: str) -> None: 

242 """Test that convert produces a valid cell_coadd JSON file from a legacy 

243 FITS MultipleCellCoadd. 

244 """ 

245 pytest.importorskip("lsst.cell_coadds") 

246 legacy_dir = os.path.join(external_data_dir, "dp2", "legacy") 

247 src = os.path.join(legacy_dir, "deep_coadd_cell_predetection.fits") 

248 skymap = os.path.join(legacy_dir, "skyMap.pickle") 

249 out = str(tmp_path / "coadd.json") 

250 result = CliRunner().invoke(main, ["convert", src, out, "--type", "cell_coadd", "--skymap", skymap]) 

251 assert result.exit_code == 0, result.output 

252 info = backend_for_path(out).input_archive.get_basic_info(out) 

253 assert info.schema_name == "cell_coadd" 

254 

255 

256def test_convert_cell_coadd_requires_skymap(tmp_path: Path, external_data_dir: str) -> None: 

257 """Test that convert fails with a helpful message when --skymap is missing 

258 for cell_coadd. 

259 """ 

260 pytest.importorskip("lsst.cell_coadds") 

261 src = os.path.join(external_data_dir, "dp2", "legacy", "deep_coadd_cell_predetection.fits") 

262 out = str(tmp_path / "coadd.json") 

263 result = CliRunner().invoke(main, ["convert", src, out, "--type", "cell_coadd"]) 

264 assert result.exit_code != 0 

265 assert "--skymap" in result.output 

266 

267 

268def test_preserve_quantization_default_is_true() -> None: 

269 """Test that the --preserve-quantization option defaults to True.""" 

270 from lsst.images.cli._convert import convert 

271 

272 option = next(p for p in convert.params if p.name == "preserve_quantization") 

273 assert option.default is True 

274 

275 

276def test_preserve_quantization_explicit_flag_rejected_for_cell_coadd(tmp_path: Path) -> None: 

277 """Test that explicitly passing --preserve-quantization is rejected for 

278 cell_coadd conversions. 

279 """ 

280 src = _make_cli_input(tmp_path) 

281 out = str(tmp_path / "out.json") 

282 result = CliRunner().invoke( 

283 main, ["convert", src, out, "--type", "cell_coadd", "--preserve-quantization"] 

284 ) 

285 assert result.exit_code != 0 

286 assert "preserve-quantization" in result.output 

287 

288 

289def test_preserve_quantization_default_does_not_reject_cell_coadd(tmp_path: Path) -> None: 

290 """Test that the --preserve-quantization option default doesn't get in the 

291 way of cell-coadd conversion. 

292 """ 

293 src = _make_cli_input(tmp_path) 

294 out = str(tmp_path / "out.json") 

295 result = CliRunner().invoke(main, ["convert", src, out, "--type", "cell_coadd"]) 

296 assert "preserve-quantization" not in result.output 

297 

298 

299def test_preserve_quantization_forwarded_to_read_legacy() -> None: 

300 """Test that _read_legacy forwards preserve_quantization=True to 

301 VisitImage.read_legacy. 

302 """ 

303 from lsst.images.cli._convert import _read_legacy 

304 

305 with mock.patch("lsst.images.VisitImage.read_legacy") as read_legacy: 

306 _read_legacy("in.fits", "visit_image", None, None, None, True) 

307 read_legacy.assert_called_once_with("in.fits", preserve_quantization=True) 

308 

309 

310def test_rejects_identical_paths(tmp_path: Path) -> None: 

311 """Test that 'convert' rejects identical src and dst paths even with 

312 --overwrite. 

313 """ 

314 path = _make_cli_input(tmp_path) 

315 result = CliRunner().invoke(main, ["convert", path, path, "--type", "visit_image", "--overwrite"]) 

316 assert result.exit_code != 0 

317 assert "different" in result.output 

318 assert os.path.exists(path) 

319 

320 

321def test_preserves_existing_output_on_read_failure(tmp_path: Path) -> None: 

322 """Test that 'convert' leaves the existing output file intact when 

323 read_legacy raises. 

324 """ 

325 src = _make_cli_input(tmp_path) 

326 out = str(tmp_path / "out.json") 

327 with open(out, "w") as stream: 

328 stream.write("ORIGINAL") 

329 with mock.patch( 

330 "lsst.images.cli._convert._read_legacy", 

331 side_effect=click.ClickException("boom"), 

332 ): 

333 result = CliRunner().invoke(main, ["convert", src, out, "--type", "visit_image", "--overwrite"]) 

334 assert result.exit_code != 0 

335 with open(out) as stream: 

336 assert stream.read() == "ORIGINAL" 

337 

338 

339def test_subcommands_present() -> None: 

340 """Test that the minify, reformat, extract-test-data, verify-rewrite, and 

341 fuzz-masked-image are listed by --help. 

342 """ 

343 result = CliRunner().invoke(main, ["--help"]) 

344 assert result.exit_code == 0, result.output 

345 assert "minify" in result.output 

346 assert "reformat" in result.output 

347 assert "extract-test-data" in result.output 

348 assert "verify-rewrite" in result.output 

349 assert "fuzz-masked-image" in result.output 

350 

351 

352def test_minify_help() -> None: 

353 """Verify minify --help exits cleanly.""" 

354 result = CliRunner().invoke(main, ["minify", "--help"]) 

355 assert result.exit_code == 0, result.output 

356 

357 

358def test_extract_test_data_help() -> None: 

359 """Verify extract-test-data --help exits cleanly.""" 

360 result = CliRunner().invoke(main, ["extract-test-data", "--help"]) 

361 assert result.exit_code == 0, result.output 

362 

363 

364def test_verify_rewrite_help() -> None: 

365 """Verify verify-rewrite and its stage4 subcommand load with core deps 

366 only. 

367 """ 

368 result = CliRunner().invoke(main, ["verify-rewrite", "--help"]) 

369 assert result.exit_code == 0, result.output 

370 assert "stage4" in result.output 

371 result = CliRunner().invoke(main, ["verify-rewrite", "stage4", "--help"]) 

372 assert result.exit_code == 0, result.output 

373 

374 

375@pytest.mark.parametrize( 

376 "args", 

377 [ 

378 ["-h"], 

379 ["convert", "-h"], 

380 ["inspect", "-h"], 

381 ["minify", "-h"], 

382 ["reformat", "-h"], 

383 ["extract-test-data", "-h"], 

384 ["extract-test-data", "dp2", "-h"], 

385 ["verify-rewrite", "-h"], 

386 ["verify-rewrite", "stage4", "-h"], 

387 ["fuzz-masked-image", "-h"], 

388 ], 

389 ids=[ 

390 "root", 

391 "convert", 

392 "inspect", 

393 "minify", 

394 "reformat", 

395 "extract-test-data", 

396 "extract-test-data-dp2", 

397 "verify-rewrite", 

398 "verify-rewrite-stage4", 

399 "fuzz-masked-image", 

400 ], 

401) 

402def test_short_help_alias(args: list[str]) -> None: 

403 """Test that -h is an alias for --help on the group and every 

404 subcommand. 

405 """ 

406 result = CliRunner().invoke(main, args) 

407 assert result.exit_code == 0, result.output 

408 assert "Usage:" in result.output