Coverage for tests/test_describe.py: 100%

225 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 08:38 +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 

16import os 

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 

35 

36 

37def test_report_model_defaults() -> None: 

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

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

40 assert field.unit is None 

41 assert field.repr_value is None 

42 assert field.role is FieldRole.ARG 

43 assert field.positional is False 

44 

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

46 assert table.role is FieldRole.DERIVED 

47 

48 report = Report(type_name="Image") 

49 assert report.title is None 

50 assert report.summary is None 

51 assert report.fields == [] 

52 assert report.tables == [] 

53 assert report.children == {} 

54 

55 

56def test_to_repr_uses_arg_fields_only() -> None: 

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

58 report = Report( 

59 type_name="Image", 

60 fields=[ 

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

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

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

64 ], 

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

66 ) 

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

68 

69 

70def test_to_repr_defaults_repr_value_to_repr_of_value() -> None: 

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

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

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

74 

75 

76def test_to_repr_supports_positional_fields() -> None: 

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

78 report = Report( 

79 type_name="MaskSchema", 

80 fields=[ 

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

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

83 ], 

84 ) 

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

86 

87 

88def test_to_str_prefers_summary() -> None: 

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

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

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

92 

93 

94def test_to_str_without_summary_lists_fields() -> None: 

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

96 report = Report( 

97 type_name="Interval", 

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

99 ) 

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

101 

102 

103def test_rich_renders_fields_tables_and_children() -> None: 

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

105 report = Report( 

106 type_name="SkyProjection", 

107 title="ICRS coordinates", 

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

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

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

111 ) 

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

113 console.print(report) 

114 text = console.export_text() 

115 assert "ICRS coordinates" in text 

116 assert "Domain" in text and "SKY" in text 

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

118 assert "pixel" in text and "GeneralFrame" in text 

119 

120 

121def test_repr_html_produces_html() -> None: 

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

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

124 html = report._repr_html_() 

125 assert "<" in html and ">" in html 

126 assert "Interval" in html 

127 

128 

129def test_repr_html_does_not_write_to_stdout() -> None: 

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

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

132 buffer = io.StringIO() 

133 with contextlib.redirect_stdout(buffer): 

134 html = report._repr_html_() 

135 assert buffer.getvalue() == "" 

136 assert "Interval" in html 

137 

138 

139def test_repr_html_does_not_publish_in_jupyter() -> None: 

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

141 

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

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

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

145 """ 

146 import rich.console as rich_console 

147 import rich.jupyter as rich_jupyter 

148 

149 published: list[str] = [] 

150 original_is_jupyter = rich_console._is_jupyter 

151 original_display = rich_jupyter.display 

152 rich_console._is_jupyter = lambda: True 

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

154 try: 

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

156 html = report._repr_html_() 

157 finally: 

158 rich_console._is_jupyter = original_is_jupyter 

159 rich_jupyter.display = original_display 

160 

161 assert published == [] 

162 assert "Interval" in html 

163 

164 

165def test_mixin_derives_dunders_from_describe() -> None: 

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

167 

168 class Widget(DescribableMixin): 

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

170 return Report( 

171 type_name="Widget", 

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

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

174 ) 

175 

176 widget = Widget() 

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

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

179 assert "Widget" in widget._repr_html_() 

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

181 

182 

183def test_public_api_importable_from_package() -> None: 

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

185 import lsst.images as images 

186 

187 for name in ( 

188 "Describable", 

189 "DescribableMixin", 

190 "DescribeOptions", 

191 "FieldRole", 

192 "Report", 

193 "ReportField", 

194 "ReportTable", 

195 "ReportValueGroup", 

196 ): 

197 assert hasattr(images, name), name 

198 

199 

200def test_visit_image_describe_nested() -> None: 

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

202 path = os.path.join(os.path.dirname(__file__), "data", "schema_v1", "visit_image.json") 

203 visit_image = read_archive(path) 

204 report = visit_image.describe() 

205 assert report.type_name == "VisitImage" 

206 # Components appear as children. 

207 assert "image" in report.children 

208 assert "mask" in report.children 

209 assert "sky_projection" in report.children 

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

211 sky = report.children["sky_projection"] 

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

213 # Rich and HTML renderers run without error. 

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

215 report.__rich__() 

216 

217 

218def test_rich_renders_bracketed_values_literally() -> None: 

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

220 report = Report( 

221 type_name="TestType", 

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

223 tables=[ 

224 ReportTable( 

225 title="Cells", 

226 columns=["Value"], 

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

228 ) 

229 ], 

230 ) 

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

232 console.print(report) 

233 text = console.export_text() 

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

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

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

237 # _repr_html_ must not raise on either bracket style. 

238 html = report._repr_html_() 

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

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

241 

242 

243def test_rich_renders_real_image_bbox_literally() -> None: 

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

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

246 report = img._describe() 

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

248 console.print(report) 

249 text = console.export_text() 

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

251 html = report._repr_html_() 

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

253 

254 

255def test_composite_report_deduplicates_bbox_and_sky_projection() -> None: 

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

257 component. 

258 """ 

259 path = os.path.join(os.path.dirname(__file__), "data", "schema_v1", "visit_image.json") 

260 report = read_archive(path).describe() 

261 

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

263 # top-level sky_projection child. 

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

265 assert "sky_projection" in report.children 

266 

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

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

269 child = report.children[name] 

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

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

272 

273 

274def test_repr_str_do_not_trigger_detail() -> None: 

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

276 counts column. 

277 """ 

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

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

280 # repr/str must be unaffected and cheap. 

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

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

283 

284 

285def test_composite_detail_propagates_to_mask_counts() -> None: 

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

287 path = os.path.join(os.path.dirname(__file__), "data", "schema_v1", "visit_image.json") 

288 visit_image = read_archive(path) 

289 

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

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

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

293 assert "Set pixels" not in plain_table.columns 

294 

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

296 # column. 

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

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

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

300 

301 

302def test_field_role_display_predicates() -> None: 

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

304 assert FieldRole.ARG.in_repr and FieldRole.ARG.in_display 

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

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

307 

308 

309def test_repr_only_fields_are_hidden_from_the_expanded_report() -> None: 

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

311 report = Report( 

312 type_name="Widget", 

313 fields=[ 

314 ReportField( 

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

316 ), 

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

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

319 ], 

320 ) 

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

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

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

324 

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

326 console.print(report) 

327 rendered = console.export_text() 

328 assert "size: 5" in rendered 

329 assert "area: 25" in rendered 

330 assert "data" not in rendered 

331 

332 

333def test_describe_options_for_child_replaces_exclude_and_keeps_the_rest() -> None: 

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

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

336 child = options.for_child("sky_projection") 

337 assert child.brief is True 

338 assert child.detail is True 

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

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

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

342 # The original is untouched; DescribeOptions is frozen. 

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

344 

345 

346def test_describe_options_are_optional_for_implementations() -> None: 

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

348 

349 class Widget(DescribableMixin): 

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

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

352 

353 widget = Widget() 

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

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

356 

357 

358def test_masked_image_report_states_the_bbox_once() -> None: 

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

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

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

362 masked = MaskedImage( 

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

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

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

366 ) 

367 report = masked.describe() 

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

369 assert labels == ["bbox"] 

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

371 assert "mask_schema=" in repr(masked) 

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

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

374 

375 

376def test_value_groups_pack_and_wrap() -> None: 

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

378 report = Report( 

379 type_name="Stats", 

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

381 ) 

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

383 console.print(report) 

384 rendered = console.export_text() 

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

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

387 for n in range(12): 

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

389 assert len(body) >= 1 

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

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

392 # back to the descriptive form. 

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

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

395 

396 

397def test_to_repr_is_descriptive_when_nothing_feeds_it() -> None: 

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

399 

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

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

402 """ 

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

404 report = Report( 

405 type_name="SkyProjection", 

406 summary="DetectorFrame → ICRS", 

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

408 ) 

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

410 

411 # Without one, the type name alone. 

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

413 

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

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

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

417 

418 

419def test_to_repr_prefers_fields_over_the_descriptive_form() -> None: 

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

421 report = Report( 

422 type_name="Image", 

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

424 fields=[ 

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

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

427 ], 

428 ) 

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

430 

431 

432def test_rich_folds_the_child_heading_into_its_key() -> None: 

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

434 

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

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

437 """ 

438 report = Report( 

439 type_name="Mask", 

440 children={ 

441 "schema": Report( 

442 type_name="MaskSchema", 

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

444 ) 

445 }, 

446 ) 

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

448 console.print(report) 

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

450 assert lines[0] == "Mask" 

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

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

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

454 assert len(lines) == 3 

455 

456 

457def test_rich_child_heading_prefers_the_title() -> None: 

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

459 report = Report( 

460 type_name="VisitImage", 

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

462 ) 

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

464 console.print(report) 

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

466 

467 

468def test_rich_inline_children_stay_on_one_line() -> None: 

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

470 report = Report( 

471 type_name="MaskedImage", 

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

473 ) 

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

475 console.print(report) 

476 text = console.export_text() 

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

478 assert "(Image)" not in text