Coverage for tests/test_describe.py: 100%

267 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 10:48 +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._observation_summary_stats import ObservationSummaryStats 

26from lsst.images.describe import ( 

27 DescribableMixin, 

28 DescribeOptions, 

29 FieldRole, 

30 Report, 

31 ReportField, 

32 ReportTable, 

33 ReportValueGroup, 

34) 

35from lsst.images.serialization import read_archive 

36from lsst.images.tests import current_fixture_path 

37 

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

39 

40 

41def test_report_model_defaults() -> None: 

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

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

44 assert field.unit is None 

45 assert field.repr_value is None 

46 assert field.role is FieldRole.ARG 

47 assert field.positional is False 

48 

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

50 assert table.role is FieldRole.DERIVED 

51 

52 report = Report(type_name="Image") 

53 assert report.title is None 

54 assert report.summary is None 

55 assert report.fields == [] 

56 assert report.tables == [] 

57 assert report.children == {} 

58 

59 

60def test_to_repr_uses_arg_fields_only() -> None: 

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

62 report = Report( 

63 type_name="Image", 

64 fields=[ 

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

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

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

68 ], 

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

70 ) 

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

72 

73 

74def test_to_repr_defaults_repr_value_to_repr_of_value() -> None: 

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

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

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

78 

79 

80def test_to_repr_supports_positional_fields() -> None: 

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

82 report = Report( 

83 type_name="MaskSchema", 

84 fields=[ 

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

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

87 ], 

88 ) 

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

90 

91 

92def test_to_str_prefers_summary() -> None: 

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

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

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

96 

97 

98def test_to_str_without_summary_lists_fields() -> None: 

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

100 report = Report( 

101 type_name="Interval", 

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

103 ) 

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

105 

106 

107def test_rich_renders_fields_tables_and_children() -> None: 

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

109 report = Report( 

110 type_name="SkyProjection", 

111 title="ICRS coordinates", 

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

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

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

115 ) 

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

117 console.print(report) 

118 text = console.export_text() 

119 assert "ICRS coordinates" in text 

120 assert "Domain" in text 

121 assert "SKY" in text 

122 assert "Axis" in text 

123 assert "Label" in text 

124 assert "RA" in text 

125 assert "pixel" in text 

126 assert "GeneralFrame" in text 

127 

128 

129def test_repr_html_produces_html() -> None: 

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

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

132 html = report._repr_html_() 

133 assert "<" in html 

134 assert ">" in html 

135 assert "Interval" in html 

136 

137 

138def test_repr_html_does_not_write_to_stdout() -> None: 

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

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

141 buffer = io.StringIO() 

142 with contextlib.redirect_stdout(buffer): 

143 html = report._repr_html_() 

144 assert buffer.getvalue() == "" 

145 assert "Interval" in html 

146 

147 

148def test_repr_html_does_not_publish_in_jupyter() -> None: 

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

150 

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

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

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

154 """ 

155 import rich.console as rich_console 

156 import rich.jupyter as rich_jupyter 

157 

158 published: list[str] = [] 

159 original_is_jupyter = rich_console._is_jupyter 

160 original_display = rich_jupyter.display 

161 rich_console._is_jupyter = lambda: True 

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

163 try: 

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

165 html = report._repr_html_() 

166 finally: 

167 rich_console._is_jupyter = original_is_jupyter 

168 rich_jupyter.display = original_display 

169 

170 assert published == [] 

171 assert "Interval" in html 

172 

173 

174def test_mixin_derives_dunders_from_describe() -> None: 

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

176 

177 class Widget(DescribableMixin): 

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

179 return Report( 

180 type_name="Widget", 

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

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

183 ) 

184 

185 widget = Widget() 

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

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

188 assert "Widget" in widget._repr_html_() 

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

190 

191 

192def test_public_api_importable_from_package() -> None: 

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

194 import lsst.images as images 

195 

196 for name in ( 

197 "Describable", 

198 "DescribableMixin", 

199 "DescribeOptions", 

200 "FieldRole", 

201 "Report", 

202 "ReportField", 

203 "ReportTable", 

204 "ReportValueGroup", 

205 ): 

206 assert hasattr(images, name), name 

207 

208 

209def test_visit_image_describe_nested() -> None: 

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

211 path = current_fixture_path(FIXTURE_DIR, "visit_image") 

212 visit_image = read_archive(path) 

213 report = visit_image.describe() 

214 assert report.type_name == "VisitImage" 

215 # Components appear as children. 

216 assert "image" in report.children 

217 assert "mask" in report.children 

218 assert "sky_projection" in report.children 

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

220 sky = report.children["sky_projection"] 

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

222 # Rich and HTML renderers run without error. 

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

224 report.__rich__() 

225 

226 

227def test_rich_renders_bracketed_values_literally() -> None: 

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

229 report = Report( 

230 type_name="TestType", 

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

232 tables=[ 

233 ReportTable( 

234 title="Cells", 

235 columns=["Value"], 

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

237 ) 

238 ], 

239 ) 

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

241 console.print(report) 

242 text = console.export_text() 

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

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

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

246 # _repr_html_ must not raise on either bracket style. 

247 html = report._repr_html_() 

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

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

250 

251 

252def test_rich_renders_real_image_bbox_literally() -> None: 

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

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

255 report = img._describe() 

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

257 console.print(report) 

258 text = console.export_text() 

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

260 html = report._repr_html_() 

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

262 

263 

264def test_composite_report_deduplicates_bbox_and_sky_projection() -> None: 

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

266 component. 

267 """ 

268 path = current_fixture_path(FIXTURE_DIR, "visit_image") 

269 report = read_archive(path).describe() 

270 

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

272 # top-level sky_projection child. 

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

274 assert "sky_projection" in report.children 

275 

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

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

278 child = report.children[name] 

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

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

281 

282 

283def test_repr_str_do_not_trigger_detail() -> None: 

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

285 counts column. 

286 """ 

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

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

289 # repr/str must be unaffected and cheap. 

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

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

292 

293 

294def test_composite_detail_propagates_to_mask_counts() -> None: 

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

296 path = current_fixture_path(FIXTURE_DIR, "visit_image") 

297 visit_image = read_archive(path) 

298 

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

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

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

302 assert "Set pixels" not in plain_table.columns 

303 

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

305 # column. 

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

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

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

309 

310 

311def test_field_role_display_predicates() -> None: 

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

313 assert FieldRole.ARG.in_repr 

314 assert FieldRole.ARG.in_display 

315 assert FieldRole.REPR_ONLY.in_repr 

316 assert not FieldRole.REPR_ONLY.in_display 

317 assert not FieldRole.DERIVED.in_repr 

318 assert FieldRole.DERIVED.in_display 

319 

320 

321def test_repr_only_fields_are_hidden_from_the_expanded_report() -> None: 

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

323 report = Report( 

324 type_name="Widget", 

325 fields=[ 

326 ReportField( 

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

328 ), 

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

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

331 ], 

332 ) 

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

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

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

336 

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

338 console.print(report) 

339 rendered = console.export_text() 

340 assert "size: 5" in rendered 

341 assert "area: 25" in rendered 

342 assert "data" not in rendered 

343 

344 

345def test_describe_options_for_child_replaces_exclude_and_keeps_the_rest() -> None: 

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

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

348 child = options.for_child("sky_projection") 

349 assert child.brief is True 

350 assert child.detail is True 

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

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

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

354 # The original is untouched; DescribeOptions is frozen. 

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

356 

357 

358def test_describe_options_are_optional_for_implementations() -> None: 

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

360 

361 class Widget(DescribableMixin): 

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

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

364 

365 widget = Widget() 

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

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

368 

369 

370def test_masked_image_report_states_the_bbox_once() -> None: 

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

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

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

374 masked = MaskedImage( 

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

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

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

378 ) 

379 report = masked.describe() 

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

381 assert labels == ["bbox"] 

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

383 assert "mask_schema=" in repr(masked) 

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

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

386 

387 

388def test_value_groups_pack_and_wrap() -> None: 

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

390 report = Report( 

391 type_name="Stats", 

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

393 ) 

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

395 console.print(report) 

396 rendered = console.export_text() 

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

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

399 for n in range(12): 

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

401 assert len(body) >= 1 

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

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

404 # back to the descriptive form. 

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

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

407 

408 

409def test_to_repr_is_descriptive_when_nothing_feeds_it() -> None: 

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

411 

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

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

414 """ 

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

416 report = Report( 

417 type_name="SkyProjection", 

418 summary="DetectorFrame → ICRS", 

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

420 ) 

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

422 

423 # Without one, the type name alone. 

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

425 

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

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

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

429 

430 

431def test_to_repr_prefers_fields_over_the_descriptive_form() -> None: 

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

433 report = Report( 

434 type_name="Image", 

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

436 fields=[ 

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

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

439 ], 

440 ) 

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

442 

443 

444def test_rich_folds_the_child_heading_into_its_key() -> None: 

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

446 

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

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

449 """ 

450 report = Report( 

451 type_name="Mask", 

452 children={ 

453 "schema": Report( 

454 type_name="MaskSchema", 

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

456 ) 

457 }, 

458 ) 

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

460 console.print(report) 

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

462 assert lines[0] == "Mask" 

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

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

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

466 assert len(lines) == 3 

467 

468 

469def test_rich_child_heading_prefers_the_title() -> None: 

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

471 report = Report( 

472 type_name="VisitImage", 

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

474 ) 

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

476 console.print(report) 

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

478 

479 

480def test_rich_inline_children_stay_on_one_line() -> None: 

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

482 report = Report( 

483 type_name="MaskedImage", 

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

485 ) 

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

487 console.print(report) 

488 text = console.export_text() 

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

490 assert "(Image)" not in text 

491 

492 

493def test_emphasis_markup_styles_only_the_marked_spans() -> None: 

494 """A marked span is styled and the rest of the headline is left alone, in 

495 the inline form as well as in a heading. 

496 """ 

497 report = Report( 

498 type_name="VisitImage", 

499 children={ 

500 "backgrounds": Report( 

501 type_name="BackgroundMap", 

502 summary="**sky [SUBTRACTED]**; also: *fringe*", 

503 inline=True, 

504 emphasis_markup=True, 

505 ) 

506 }, 

507 ) 

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

509 console.print(report) 

510 # The delimiters go; the text they marked is untouched. 

511 assert "backgrounds: sky [SUBTRACTED]; also: fringe" in console.export_text() 

512 html = report._repr_html_() 

513 assert '<span style="font-weight: bold">sky [SUBTRACTED]</span>; also: ' in html 

514 assert '<span style="font-style: italic">fringe</span>' in html 

515 # Plain-text forms read the headline, so never show a delimiter. 

516 background = report.children["backgrounds"] 

517 assert background.to_str() == "sky [SUBTRACTED]; also: fringe" 

518 assert background.to_repr() == "<BackgroundMap: sky [SUBTRACTED]; also: fringe>" 

519 

520 

521def test_emphasis_markup_leaves_bare_asterisks_alone() -> None: 

522 """A bare asterisk is content, not a delimiter: always without the flag, 

523 and with it unless the asterisks pair up around a span. 

524 """ 

525 report = Report(type_name="Image", summary="Image(**kwargs)", inline=True) 

526 assert report.emphasis_markup is False 

527 assert report.to_str() == "Image(**kwargs)" 

528 parent = Report(type_name="MaskedImage", children={"image": report}) 

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

530 console.print(parent) 

531 assert "image: Image(**kwargs)" in console.export_text() 

532 assert "font-weight: bold" not in parent._repr_html_() 

533 # With the flag, an unpaired delimiter, as a squared unit carries, is 

534 # still content. 

535 report = Report(type_name="Field", summary="unit electron**2", emphasis_markup=True) 

536 assert report.to_str() == "unit electron**2" 

537 

538 

539def test_summary_stats_corners_excluded_on_request() -> None: 

540 """Excluding "corners" drops the sky corners but not the count.""" 

541 stats = ObservationSummaryStats( 

542 psfSigma=2.5, 

543 raCorners=(1.0, 2.0, 3.0, 4.0), 

544 decCorners=(-1.0, -2.0, -3.0, -4.0), 

545 ) 

546 full = stats.describe() 

547 labels = {field.label for field in full.fields} 

548 assert {"raCorners", "decCorners"} <= labels 

549 trimmed = stats.describe(exclude=["corners"]) 

550 assert not {"raCorners", "decCorners"} & {field.label for field in trimmed.fields} 

551 # The count covers what is set, not what was rendered, so it must not move. 

552 assert trimmed.summary == full.summary 

553 # Nothing else is dropped. 

554 assert trimmed.value_groups == full.value_groups