Coverage for tests/test_describe.py: 100%

227 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-13 10:41 +0000

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 

14import contextlib 

15import io 

16from pathlib import Path 

17 

18import numpy as np 

19from rich.console import Console 

20 

21from lsst.images._geom import Box 

22from lsst.images._image import Image 

23from lsst.images._mask import Mask, MaskPlane, MaskSchema 

24from lsst.images._masked_image import MaskedImage 

25from lsst.images.describe import ( 

26 DescribableMixin, 

27 DescribeOptions, 

28 FieldRole, 

29 Report, 

30 ReportField, 

31 ReportTable, 

32 ReportValueGroup, 

33) 

34from lsst.images.serialization import read_archive 

35from lsst.images.tests import current_fixture_path 

36 

37FIXTURE_DIR = Path(__file__).parent / "data" / "schemas" 

38 

39 

40def test_report_model_defaults() -> None: 

41 """Report and its components have the expected fields and defaults.""" 

42 field = ReportField(label="bbox", value="[y=0:4, x=0:4]") 

43 assert field.unit is None 

44 assert field.repr_value is None 

45 assert field.role is FieldRole.ARG 

46 assert field.positional is False 

47 

48 table = ReportTable(title="Axes", columns=["Axis", "Label"], rows=[[1, "RA"]]) 

49 assert table.role is FieldRole.DERIVED 

50 

51 report = Report(type_name="Image") 

52 assert report.title is None 

53 assert report.summary is None 

54 assert report.fields == [] 

55 assert report.tables == [] 

56 assert report.children == {} 

57 

58 

59def test_to_repr_uses_arg_fields_only() -> None: 

60 """to_repr emits Type(label=repr_value) for ARG fields, skipping others.""" 

61 report = Report( 

62 type_name="Image", 

63 fields=[ 

64 ReportField(label="bbox", value="[y=0:4, x=0:4]", repr_value="Box(...)"), 

65 ReportField(label="array", value="<huge>", repr_value="...", role=FieldRole.ARG), 

66 ReportField(label="corner", value="10:04:21", role=FieldRole.DERIVED), 

67 ], 

68 tables=[ReportTable(title="T", columns=["a"], rows=[[1]])], 

69 ) 

70 assert report.to_repr() == "Image(bbox=Box(...), array=...)" 

71 

72 

73def test_to_repr_defaults_repr_value_to_repr_of_value() -> None: 

74 """A field with no repr_value falls back to repr(value).""" 

75 report = Report(type_name="Interval", fields=[ReportField(label="start", value=3)]) 

76 assert report.to_repr() == "Interval(start=3)" 

77 

78 

79def test_to_repr_supports_positional_fields() -> None: 

80 """Positional ARG fields emit their value without a label=.""" 

81 report = Report( 

82 type_name="MaskSchema", 

83 fields=[ 

84 ReportField(label="planes", value="[...]", repr_value="[...]", positional=True), 

85 ReportField(label="dtype", value="uint8", repr_value="dtype('uint8')"), 

86 ], 

87 ) 

88 assert report.to_repr() == "MaskSchema([...], dtype=dtype('uint8'))" 

89 

90 

91def test_to_str_prefers_summary() -> None: 

92 """to_str returns the summary verbatim when present.""" 

93 report = Report(type_name="Image", summary="Image([y=0:4, x=0:4], float32)") 

94 assert report.to_str() == "Image([y=0:4, x=0:4], float32)" 

95 

96 

97def test_to_str_without_summary_lists_fields() -> None: 

98 """to_str falls back to type name plus the first few ARG values.""" 

99 report = Report( 

100 type_name="Interval", 

101 fields=[ReportField(label="start", value=0), ReportField(label="stop", value=4)], 

102 ) 

103 assert report.to_str() == "Interval(0, 4)" 

104 

105 

106def test_rich_renders_fields_tables_and_children() -> None: 

107 """__rich__ output contains labels, table headers, and child keys.""" 

108 report = Report( 

109 type_name="SkyProjection", 

110 title="ICRS coordinates", 

111 fields=[ReportField(label="Domain", value="SKY")], 

112 tables=[ReportTable(title="Axes", columns=["Axis", "Label"], rows=[[1, "RA"], [2, "Dec"]])], 

113 children={"pixel": Report(type_name="GeneralFrame", fields=[ReportField(label="unit", value="pix")])}, 

114 ) 

115 console = Console(record=True, width=100) 

116 console.print(report) 

117 text = console.export_text() 

118 assert "ICRS coordinates" in text 

119 assert "Domain" in text and "SKY" in text 

120 assert "Axis" in text and "Label" in text and "RA" in text 

121 assert "pixel" in text and "GeneralFrame" in text 

122 

123 

124def test_repr_html_produces_html() -> None: 

125 """_repr_html_ returns an HTML fragment mentioning the content.""" 

126 report = Report(type_name="Interval", fields=[ReportField(label="start", value=3)]) 

127 html = report._repr_html_() 

128 assert "<" in html and ">" in html 

129 assert "Interval" in html 

130 

131 

132def test_repr_html_does_not_write_to_stdout() -> None: 

133 """_repr_html_ returns HTML without also printing to stdout.""" 

134 report = Report(type_name="Interval", fields=[ReportField(label="start", value=3)]) 

135 buffer = io.StringIO() 

136 with contextlib.redirect_stdout(buffer): 

137 html = report._repr_html_() 

138 assert buffer.getvalue() == "" 

139 assert "Interval" in html 

140 

141 

142def test_repr_html_does_not_publish_in_jupyter() -> None: 

143 """_repr_html_ returns HTML without also publishing it to the notebook. 

144 

145 Inside Jupyter a Jupyter-aware rich console publishes its render as a 

146 side effect, which would double the displayed output; the console must 

147 stay in file mode so only the returned HTML reaches the frontend. 

148 """ 

149 import rich.console as rich_console 

150 import rich.jupyter as rich_jupyter 

151 

152 published: list[str] = [] 

153 original_is_jupyter = rich_console._is_jupyter 

154 original_display = rich_jupyter.display 

155 rich_console._is_jupyter = lambda: True 

156 rich_jupyter.display = lambda segments, text: published.append(text) 

157 try: 

158 report = Report(type_name="Interval", fields=[ReportField(label="start", value=3)]) 

159 html = report._repr_html_() 

160 finally: 

161 rich_console._is_jupyter = original_is_jupyter 

162 rich_jupyter.display = original_display 

163 

164 assert published == [] 

165 assert "Interval" in html 

166 

167 

168def test_mixin_derives_dunders_from_describe() -> None: 

169 """DescribableMixin wires repr/str/html to _describe.""" 

170 

171 class Widget(DescribableMixin): 

172 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report: 

173 return Report( 

174 type_name="Widget", 

175 summary="Widget(size=5)", 

176 fields=[ReportField(label="size", value=5)], 

177 ) 

178 

179 widget = Widget() 

180 assert repr(widget) == "Widget(size=5)" 

181 assert str(widget) == "Widget(size=5)" 

182 assert "Widget" in widget._repr_html_() 

183 assert isinstance(widget.describe(), Report) 

184 

185 

186def test_public_api_importable_from_package() -> None: 

187 """The describe public API is re-exported from lsst.images.""" 

188 import lsst.images as images 

189 

190 for name in ( 

191 "Describable", 

192 "DescribableMixin", 

193 "DescribeOptions", 

194 "FieldRole", 

195 "Report", 

196 "ReportField", 

197 "ReportTable", 

198 "ReportValueGroup", 

199 ): 

200 assert hasattr(images, name), name 

201 

202 

203def test_visit_image_describe_nested() -> None: 

204 """A deserialized VisitImage produces a nested report with WCS corners.""" 

205 path = current_fixture_path(FIXTURE_DIR, "visit_image") 

206 visit_image = read_archive(path) 

207 report = visit_image.describe() 

208 assert report.type_name == "VisitImage" 

209 # Components appear as children. 

210 assert "image" in report.children 

211 assert "mask" in report.children 

212 assert "sky_projection" in report.children 

213 # The sky_projection child received the container bbox, so it has corners. 

214 sky = report.children["sky_projection"] 

215 assert any(t.title == "Corners" for t in sky.tables) 

216 # Rich and HTML renderers run without error. 

217 assert isinstance(report._repr_html_(), str) 

218 report.__rich__() 

219 

220 

221def test_rich_renders_bracketed_values_literally() -> None: 

222 """Bracketed strings in field values and table cells render verbatim.""" 

223 report = Report( 

224 type_name="TestType", 

225 fields=[ReportField(label="region", value="[y=0:4, x=0:4]")], 

226 tables=[ 

227 ReportTable( 

228 title="Cells", 

229 columns=["Value"], 

230 rows=[["[y=0:4, x=0:4]"], ["[/x=0:4]"]], 

231 ) 

232 ], 

233 ) 

234 console = Console(record=True, width=120) 

235 console.print(report) 

236 text = console.export_text() 

237 # Both bracket styles must appear verbatim in the exported text. 

238 assert "[y=0:4, x=0:4]" in text 

239 assert "[/x=0:4]" in text 

240 # _repr_html_ must not raise on either bracket style. 

241 html = report._repr_html_() 

242 assert "[y=0:4, x=0:4]" in html 

243 assert "[/x=0:4]" in html 

244 

245 

246def test_rich_renders_real_image_bbox_literally() -> None: 

247 """A real Image with a bracketed bbox renders the bbox verbatim.""" 

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

249 report = img._describe() 

250 console = Console(record=True, width=120) 

251 console.print(report) 

252 text = console.export_text() 

253 assert "[y=0:4, x=0:4]" in text 

254 html = report._repr_html_() 

255 assert "[y=0:4, x=0:4]" in html 

256 

257 

258def test_composite_report_deduplicates_bbox_and_sky_projection() -> None: 

259 """Composite reports show bbox and sky_projection once, not per 

260 component. 

261 """ 

262 path = current_fixture_path(FIXTURE_DIR, "visit_image") 

263 report = read_archive(path).describe() 

264 

265 # The composite carries exactly one top-level bbox field and one 

266 # top-level sky_projection child. 

267 assert sum(1 for f in report.fields if f.label == "bbox") == 1 

268 assert "sky_projection" in report.children 

269 

270 # The image/mask/variance children no longer repeat the shared geometry. 

271 for name in ("image", "mask", "variance"): 

272 child = report.children[name] 

273 assert not any(f.label == "bbox" for f in child.fields), name 

274 assert "sky_projection" not in child.children, name 

275 

276 

277def test_repr_str_do_not_trigger_detail() -> None: 

278 """Repr and str never pass detail; a MaskSchema report from repr has no 

279 counts column. 

280 """ 

281 schema = MaskSchema([MaskPlane("BAD", "bad")], dtype=np.uint8) 

282 mask = Mask(0, schema=schema, bbox=Box.factory[0:2, 0:2]) 

283 # repr/str must be unaffected and cheap. 

284 assert repr(mask).startswith("Mask(") 

285 assert str(mask).startswith("Mask(") 

286 

287 

288def test_composite_detail_propagates_to_mask_counts() -> None: 

289 """describe(detail=True) on a composite reaches the nested mask counts.""" 

290 path = current_fixture_path(FIXTURE_DIR, "visit_image") 

291 visit_image = read_archive(path) 

292 

293 # Cheap composite report: mask schema table has no counts column. 

294 plain = visit_image.describe().children["mask"].children["schema"] 

295 plain_table = next(t for t in plain.tables if t.title == "Mask planes") 

296 assert "Set pixels" not in plain_table.columns 

297 

298 # Detailed composite report: the nested mask schema gains the counts 

299 # column. 

300 detailed = visit_image.describe(detail=True).children["mask"].children["schema"] 

301 table = next(t for t in detailed.tables if t.title == "Mask planes") 

302 assert table.columns[-1] == "Set pixels" 

303 

304 

305def test_field_role_display_predicates() -> None: 

306 """Each FieldRole reports where its fields appear.""" 

307 assert FieldRole.ARG.in_repr and FieldRole.ARG.in_display 

308 assert FieldRole.REPR_ONLY.in_repr and not FieldRole.REPR_ONLY.in_display 

309 assert not FieldRole.DERIVED.in_repr and FieldRole.DERIVED.in_display 

310 

311 

312def test_repr_only_fields_are_hidden_from_the_expanded_report() -> None: 

313 """REPR_ONLY fields feed repr and str but never the rendered tree.""" 

314 report = Report( 

315 type_name="Widget", 

316 fields=[ 

317 ReportField( 

318 label="data", value="<array>", repr_value="...", positional=True, role=FieldRole.REPR_ONLY 

319 ), 

320 ReportField(label="size", value=5), 

321 ReportField(label="area", value=25, role=FieldRole.DERIVED), 

322 ], 

323 ) 

324 assert report.to_repr() == "Widget(..., size=5)" 

325 # to_str has no summary here, so it falls back to the repr-feeding fields. 

326 assert report.to_str() == "Widget(<array>, 5)" 

327 

328 console = Console(record=True, width=80, file=io.StringIO(), force_jupyter=False) 

329 console.print(report) 

330 rendered = console.export_text() 

331 assert "size: 5" in rendered 

332 assert "area: 25" in rendered 

333 assert "data" not in rendered 

334 

335 

336def test_describe_options_for_child_replaces_exclude_and_keeps_the_rest() -> None: 

337 """for_child carries brief and detail down but resets exclude.""" 

338 options = DescribeOptions(brief=True, detail=True, exclude=frozenset({"bbox"})) 

339 child = options.for_child("sky_projection") 

340 assert child.brief is True 

341 assert child.detail is True 

342 assert child.exclude == frozenset({"sky_projection"}) 

343 # With no arguments the child inherits no exclusions at all. 

344 assert options.for_child().exclude == frozenset() 

345 # The original is untouched; DescribeOptions is frozen. 

346 assert options.exclude == frozenset({"bbox"}) 

347 

348 

349def test_describe_options_are_optional_for_implementations() -> None: 

350 """An implementation may ignore options entirely and still describe.""" 

351 

352 class Widget(DescribableMixin): 

353 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report: 

354 return Report(type_name="Widget", fields=[ReportField(label="size", value=5)]) 

355 

356 widget = Widget() 

357 assert widget.describe(brief=True, detail=True, exclude=("bbox",)).to_repr() == "Widget(size=5)" 

358 assert repr(widget) == "Widget(size=5)" 

359 

360 

361def test_masked_image_report_states_the_bbox_once() -> None: 

362 """The image and mask schema reach repr without duplicating the tree.""" 

363 bbox = Box.factory[0:4, 0:4] 

364 schema = MaskSchema([MaskPlane("BAD", "Bad pixel")], dtype=np.uint8) 

365 masked = MaskedImage( 

366 image=Image(0.0, bbox=bbox), 

367 mask=Mask(0, bbox=bbox, schema=schema), 

368 variance=Image(1.0, bbox=bbox), 

369 ) 

370 report = masked.describe() 

371 labels = [f.label for f in report.fields if f.role.in_display] 

372 assert labels == ["bbox"] 

373 # Both are still recoverable from repr, and appear as children. 

374 assert "mask_schema=" in repr(masked) 

375 assert repr(masked).startswith("MaskedImage(Image(") 

376 assert {"image", "mask", "variance"} <= set(report.children) 

377 

378 

379def test_value_groups_pack_and_wrap() -> None: 

380 """A ReportValueGroup packs several values per line, wrapping to width.""" 

381 report = Report( 

382 type_name="Stats", 

383 value_groups=[ReportValueGroup(values=[(f"field{n}", n) for n in range(12)])], 

384 ) 

385 console = Console(record=True, width=60, file=io.StringIO(), force_jupyter=False) 

386 console.print(report) 

387 rendered = console.export_text() 

388 body = [line for line in rendered.splitlines() if "field0=" in line or "field11=" in line] 

389 # All twelve values are present, packed onto fewer lines than values. 

390 for n in range(12): 

391 assert f"field{n}={n}" in rendered 

392 assert len(body) >= 1 

393 assert len(rendered.splitlines()) < 12 

394 # Groups are display-only, so nothing here feeds repr and the report falls 

395 # back to the descriptive form. 

396 assert report.to_repr() == "<Stats>" 

397 assert not any(f"field{n}" in report.to_repr() for n in range(12)) 

398 

399 

400def test_to_repr_is_descriptive_when_nothing_feeds_it() -> None: 

401 """A report with no repr-feeding fields gets the angle-bracket form. 

402 

403 An empty ``Type()`` would read as a constructor call that reproduces the 

404 object, which is exactly what these types cannot do. 

405 """ 

406 # With a summary, the type name and the summary both appear. 

407 report = Report( 

408 type_name="SkyProjection", 

409 summary="DetectorFrame → ICRS", 

410 fields=[ReportField(label="scale", value="0.2 arcsec", role=FieldRole.DERIVED)], 

411 ) 

412 assert report.to_repr() == "<SkyProjection: DetectorFrame → ICRS>" 

413 

414 # Without one, the type name alone. 

415 assert Report(type_name="SkyProjection").to_repr() == "<SkyProjection>" 

416 

417 # A summary that already opens with the type name does not repeat it. 

418 named = Report(type_name="Detector", summary="Detector 'R21_S11' (LSSTCam)") 

419 assert named.to_repr() == "<Detector 'R21_S11' (LSSTCam)>" 

420 

421 

422def test_to_repr_prefers_fields_over_the_descriptive_form() -> None: 

423 """A single repr-feeding field is enough to keep the eval-ish form.""" 

424 report = Report( 

425 type_name="Image", 

426 summary="Image([y=0:4, x=0:4], float32)", 

427 fields=[ 

428 ReportField(label="dtype", value="float32", repr_value="dtype('float32')"), 

429 ReportField(label="bbox", value="[y=0:4]", role=FieldRole.DERIVED), 

430 ], 

431 ) 

432 assert report.to_repr() == "Image(dtype=dtype('float32'))" 

433 

434 

435def test_rich_folds_the_child_heading_into_its_key() -> None: 

436 """A nested child names itself and its type on the key's own line. 

437 

438 Rendering the key and the child's heading on separate lines would spend a 

439 level of indentation on each nesting step without adding information. 

440 """ 

441 report = Report( 

442 type_name="Mask", 

443 children={ 

444 "schema": Report( 

445 type_name="MaskSchema", 

446 fields=[ReportField(label="dtype", value="uint8")], 

447 ) 

448 }, 

449 ) 

450 console = Console(record=True, width=80, file=io.StringIO(), force_jupyter=False) 

451 console.print(report) 

452 lines = [line.rstrip() for line in console.export_text().splitlines() if line.strip()] 

453 assert lines[0] == "Mask" 

454 assert lines[1].endswith("schema (MaskSchema)") 

455 # The child's fields sit one level in, not two. 

456 assert lines[2].endswith("dtype: uint8") 

457 assert len(lines) == 3 

458 

459 

460def test_rich_child_heading_prefers_the_title() -> None: 

461 """A child with a title is named by it rather than by its type.""" 

462 report = Report( 

463 type_name="VisitImage", 

464 children={"sky_projection": Report(type_name="SkyProjection", title="ICRS coordinates")}, 

465 ) 

466 console = Console(record=True, width=80, file=io.StringIO(), force_jupyter=False) 

467 console.print(report) 

468 assert "sky_projection (ICRS coordinates)" in console.export_text() 

469 

470 

471def test_rich_inline_children_stay_on_one_line() -> None: 

472 """An inline child keeps its summary form and gains no type suffix.""" 

473 report = Report( 

474 type_name="MaskedImage", 

475 children={"image": Report(type_name="Image", summary="(dtype float32)", inline=True)}, 

476 ) 

477 console = Console(record=True, width=80, file=io.StringIO(), force_jupyter=False) 

478 console.print(report) 

479 text = console.export_text() 

480 assert "image: (dtype float32)" in text 

481 assert "(Image)" not in text