Coverage for tests/test_describe.py: 100%
235 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-19 02:55 -0700
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-19 02:55 -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 contextlib
15import io
16from pathlib import Path
18import numpy as np
19from rich.console import Console
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
37FIXTURE_DIR = Path(__file__).parent / "data" / "schemas"
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
48 table = ReportTable(title="Axes", columns=["Axis", "Label"], rows=[[1, "RA"]])
49 assert table.role is FieldRole.DERIVED
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 == {}
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=...)"
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)"
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'))"
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)"
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)"
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
120 assert "SKY" in text
121 assert "Axis" in text
122 assert "Label" in text
123 assert "RA" in text
124 assert "pixel" in text
125 assert "GeneralFrame" in text
128def test_repr_html_produces_html() -> None:
129 """_repr_html_ returns an HTML fragment mentioning the content."""
130 report = Report(type_name="Interval", fields=[ReportField(label="start", value=3)])
131 html = report._repr_html_()
132 assert "<" in html
133 assert ">" in html
134 assert "Interval" in html
137def test_repr_html_does_not_write_to_stdout() -> None:
138 """_repr_html_ returns HTML without also printing to stdout."""
139 report = Report(type_name="Interval", fields=[ReportField(label="start", value=3)])
140 buffer = io.StringIO()
141 with contextlib.redirect_stdout(buffer):
142 html = report._repr_html_()
143 assert buffer.getvalue() == ""
144 assert "Interval" in html
147def test_repr_html_does_not_publish_in_jupyter() -> None:
148 """_repr_html_ returns HTML without also publishing it to the notebook.
150 Inside Jupyter a Jupyter-aware rich console publishes its render as a
151 side effect, which would double the displayed output; the console must
152 stay in file mode so only the returned HTML reaches the frontend.
153 """
154 import rich.console as rich_console
155 import rich.jupyter as rich_jupyter
157 published: list[str] = []
158 original_is_jupyter = rich_console._is_jupyter
159 original_display = rich_jupyter.display
160 rich_console._is_jupyter = lambda: True
161 rich_jupyter.display = lambda segments, text: published.append(text)
162 try:
163 report = Report(type_name="Interval", fields=[ReportField(label="start", value=3)])
164 html = report._repr_html_()
165 finally:
166 rich_console._is_jupyter = original_is_jupyter
167 rich_jupyter.display = original_display
169 assert published == []
170 assert "Interval" in html
173def test_mixin_derives_dunders_from_describe() -> None:
174 """DescribableMixin wires repr/str/html to _describe."""
176 class Widget(DescribableMixin):
177 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
178 return Report(
179 type_name="Widget",
180 summary="Widget(size=5)",
181 fields=[ReportField(label="size", value=5)],
182 )
184 widget = Widget()
185 assert repr(widget) == "Widget(size=5)"
186 assert str(widget) == "Widget(size=5)"
187 assert "Widget" in widget._repr_html_()
188 assert isinstance(widget.describe(), Report)
191def test_public_api_importable_from_package() -> None:
192 """The describe public API is re-exported from lsst.images."""
193 import lsst.images as images
195 for name in (
196 "Describable",
197 "DescribableMixin",
198 "DescribeOptions",
199 "FieldRole",
200 "Report",
201 "ReportField",
202 "ReportTable",
203 "ReportValueGroup",
204 ):
205 assert hasattr(images, name), name
208def test_visit_image_describe_nested() -> None:
209 """A deserialized VisitImage produces a nested report with WCS corners."""
210 path = current_fixture_path(FIXTURE_DIR, "visit_image")
211 visit_image = read_archive(path)
212 report = visit_image.describe()
213 assert report.type_name == "VisitImage"
214 # Components appear as children.
215 assert "image" in report.children
216 assert "mask" in report.children
217 assert "sky_projection" in report.children
218 # The sky_projection child received the container bbox, so it has corners.
219 sky = report.children["sky_projection"]
220 assert any(t.title == "Corners" for t in sky.tables)
221 # Rich and HTML renderers run without error.
222 assert isinstance(report._repr_html_(), str)
223 report.__rich__()
226def test_rich_renders_bracketed_values_literally() -> None:
227 """Bracketed strings in field values and table cells render verbatim."""
228 report = Report(
229 type_name="TestType",
230 fields=[ReportField(label="region", value="[y=0:4, x=0:4]")],
231 tables=[
232 ReportTable(
233 title="Cells",
234 columns=["Value"],
235 rows=[["[y=0:4, x=0:4]"], ["[/x=0:4]"]],
236 )
237 ],
238 )
239 console = Console(record=True, width=120)
240 console.print(report)
241 text = console.export_text()
242 # Both bracket styles must appear verbatim in the exported text.
243 assert "[y=0:4, x=0:4]" in text
244 assert "[/x=0:4]" in text
245 # _repr_html_ must not raise on either bracket style.
246 html = report._repr_html_()
247 assert "[y=0:4, x=0:4]" in html
248 assert "[/x=0:4]" in html
251def test_rich_renders_real_image_bbox_literally() -> None:
252 """A real Image with a bracketed bbox renders the bbox verbatim."""
253 img = Image(np.zeros((4, 4), dtype=np.float32), bbox=Box.factory[0:4, 0:4])
254 report = img._describe()
255 console = Console(record=True, width=120)
256 console.print(report)
257 text = console.export_text()
258 assert "[y=0:4, x=0:4]" in text
259 html = report._repr_html_()
260 assert "[y=0:4, x=0:4]" in html
263def test_composite_report_deduplicates_bbox_and_sky_projection() -> None:
264 """Composite reports show bbox and sky_projection once, not per
265 component.
266 """
267 path = current_fixture_path(FIXTURE_DIR, "visit_image")
268 report = read_archive(path).describe()
270 # The composite carries exactly one top-level bbox field and one
271 # top-level sky_projection child.
272 assert sum(1 for f in report.fields if f.label == "bbox") == 1
273 assert "sky_projection" in report.children
275 # The image/mask/variance children no longer repeat the shared geometry.
276 for name in ("image", "mask", "variance"):
277 child = report.children[name]
278 assert not any(f.label == "bbox" for f in child.fields), name
279 assert "sky_projection" not in child.children, name
282def test_repr_str_do_not_trigger_detail() -> None:
283 """Repr and str never pass detail; a MaskSchema report from repr has no
284 counts column.
285 """
286 schema = MaskSchema([MaskPlane("BAD", "bad")], dtype=np.uint8)
287 mask = Mask(0, schema=schema, bbox=Box.factory[0:2, 0:2])
288 # repr/str must be unaffected and cheap.
289 assert repr(mask).startswith("Mask(")
290 assert str(mask).startswith("Mask(")
293def test_composite_detail_propagates_to_mask_counts() -> None:
294 """describe(detail=True) on a composite reaches the nested mask counts."""
295 path = current_fixture_path(FIXTURE_DIR, "visit_image")
296 visit_image = read_archive(path)
298 # Cheap composite report: mask schema table has no counts column.
299 plain = visit_image.describe().children["mask"].children["schema"]
300 plain_table = next(t for t in plain.tables if t.title == "Mask planes")
301 assert "Set pixels" not in plain_table.columns
303 # Detailed composite report: the nested mask schema gains the counts
304 # column.
305 detailed = visit_image.describe(detail=True).children["mask"].children["schema"]
306 table = next(t for t in detailed.tables if t.title == "Mask planes")
307 assert table.columns[-1] == "Set pixels"
310def test_field_role_display_predicates() -> None:
311 """Each FieldRole reports where its fields appear."""
312 assert FieldRole.ARG.in_repr
313 assert FieldRole.ARG.in_display
314 assert FieldRole.REPR_ONLY.in_repr
315 assert not FieldRole.REPR_ONLY.in_display
316 assert not FieldRole.DERIVED.in_repr
317 assert FieldRole.DERIVED.in_display
320def test_repr_only_fields_are_hidden_from_the_expanded_report() -> None:
321 """REPR_ONLY fields feed repr and str but never the rendered tree."""
322 report = Report(
323 type_name="Widget",
324 fields=[
325 ReportField(
326 label="data", value="<array>", repr_value="...", positional=True, role=FieldRole.REPR_ONLY
327 ),
328 ReportField(label="size", value=5),
329 ReportField(label="area", value=25, role=FieldRole.DERIVED),
330 ],
331 )
332 assert report.to_repr() == "Widget(..., size=5)"
333 # to_str has no summary here, so it falls back to the repr-feeding fields.
334 assert report.to_str() == "Widget(<array>, 5)"
336 console = Console(record=True, width=80, file=io.StringIO(), force_jupyter=False)
337 console.print(report)
338 rendered = console.export_text()
339 assert "size: 5" in rendered
340 assert "area: 25" in rendered
341 assert "data" not in rendered
344def test_describe_options_for_child_replaces_exclude_and_keeps_the_rest() -> None:
345 """for_child carries brief and detail down but resets exclude."""
346 options = DescribeOptions(brief=True, detail=True, exclude=frozenset({"bbox"}))
347 child = options.for_child("sky_projection")
348 assert child.brief is True
349 assert child.detail is True
350 assert child.exclude == frozenset({"sky_projection"})
351 # With no arguments the child inherits no exclusions at all.
352 assert options.for_child().exclude == frozenset()
353 # The original is untouched; DescribeOptions is frozen.
354 assert options.exclude == frozenset({"bbox"})
357def test_describe_options_are_optional_for_implementations() -> None:
358 """An implementation may ignore options entirely and still describe."""
360 class Widget(DescribableMixin):
361 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
362 return Report(type_name="Widget", fields=[ReportField(label="size", value=5)])
364 widget = Widget()
365 assert widget.describe(brief=True, detail=True, exclude=("bbox",)).to_repr() == "Widget(size=5)"
366 assert repr(widget) == "Widget(size=5)"
369def test_masked_image_report_states_the_bbox_once() -> None:
370 """The image and mask schema reach repr without duplicating the tree."""
371 bbox = Box.factory[0:4, 0:4]
372 schema = MaskSchema([MaskPlane("BAD", "Bad pixel")], dtype=np.uint8)
373 masked = MaskedImage(
374 image=Image(0.0, bbox=bbox),
375 mask=Mask(0, bbox=bbox, schema=schema),
376 variance=Image(1.0, bbox=bbox),
377 )
378 report = masked.describe()
379 labels = [f.label for f in report.fields if f.role.in_display]
380 assert labels == ["bbox"]
381 # Both are still recoverable from repr, and appear as children.
382 assert "mask_schema=" in repr(masked)
383 assert repr(masked).startswith("MaskedImage(Image(")
384 assert {"image", "mask", "variance"} <= set(report.children)
387def test_value_groups_pack_and_wrap() -> None:
388 """A ReportValueGroup packs several values per line, wrapping to width."""
389 report = Report(
390 type_name="Stats",
391 value_groups=[ReportValueGroup(values=[(f"field{n}", n) for n in range(12)])],
392 )
393 console = Console(record=True, width=60, file=io.StringIO(), force_jupyter=False)
394 console.print(report)
395 rendered = console.export_text()
396 body = [line for line in rendered.splitlines() if "field0=" in line or "field11=" in line]
397 # All twelve values are present, packed onto fewer lines than values.
398 for n in range(12):
399 assert f"field{n}={n}" in rendered
400 assert len(body) >= 1
401 assert len(rendered.splitlines()) < 12
402 # Groups are display-only, so nothing here feeds repr and the report falls
403 # back to the descriptive form.
404 assert report.to_repr() == "<Stats>"
405 assert not any(f"field{n}" in report.to_repr() for n in range(12))
408def test_to_repr_is_descriptive_when_nothing_feeds_it() -> None:
409 """A report with no repr-feeding fields gets the angle-bracket form.
411 An empty ``Type()`` would read as a constructor call that reproduces the
412 object, which is exactly what these types cannot do.
413 """
414 # With a summary, the type name and the summary both appear.
415 report = Report(
416 type_name="SkyProjection",
417 summary="DetectorFrame → ICRS",
418 fields=[ReportField(label="scale", value="0.2 arcsec", role=FieldRole.DERIVED)],
419 )
420 assert report.to_repr() == "<SkyProjection: DetectorFrame → ICRS>"
422 # Without one, the type name alone.
423 assert Report(type_name="SkyProjection").to_repr() == "<SkyProjection>"
425 # A summary that already opens with the type name does not repeat it.
426 named = Report(type_name="Detector", summary="Detector 'R21_S11' (LSSTCam)")
427 assert named.to_repr() == "<Detector 'R21_S11' (LSSTCam)>"
430def test_to_repr_prefers_fields_over_the_descriptive_form() -> None:
431 """A single repr-feeding field is enough to keep the eval-ish form."""
432 report = Report(
433 type_name="Image",
434 summary="Image([y=0:4, x=0:4], float32)",
435 fields=[
436 ReportField(label="dtype", value="float32", repr_value="dtype('float32')"),
437 ReportField(label="bbox", value="[y=0:4]", role=FieldRole.DERIVED),
438 ],
439 )
440 assert report.to_repr() == "Image(dtype=dtype('float32'))"
443def test_rich_folds_the_child_heading_into_its_key() -> None:
444 """A nested child names itself and its type on the key's own line.
446 Rendering the key and the child's heading on separate lines would spend a
447 level of indentation on each nesting step without adding information.
448 """
449 report = Report(
450 type_name="Mask",
451 children={
452 "schema": Report(
453 type_name="MaskSchema",
454 fields=[ReportField(label="dtype", value="uint8")],
455 )
456 },
457 )
458 console = Console(record=True, width=80, file=io.StringIO(), force_jupyter=False)
459 console.print(report)
460 lines = [line.rstrip() for line in console.export_text().splitlines() if line.strip()]
461 assert lines[0] == "Mask"
462 assert lines[1].endswith("schema (MaskSchema)")
463 # The child's fields sit one level in, not two.
464 assert lines[2].endswith("dtype: uint8")
465 assert len(lines) == 3
468def test_rich_child_heading_prefers_the_title() -> None:
469 """A child with a title is named by it rather than by its type."""
470 report = Report(
471 type_name="VisitImage",
472 children={"sky_projection": Report(type_name="SkyProjection", title="ICRS coordinates")},
473 )
474 console = Console(record=True, width=80, file=io.StringIO(), force_jupyter=False)
475 console.print(report)
476 assert "sky_projection (ICRS coordinates)" in console.export_text()
479def test_rich_inline_children_stay_on_one_line() -> None:
480 """An inline child keeps its summary form and gains no type suffix."""
481 report = Report(
482 type_name="MaskedImage",
483 children={"image": Report(type_name="Image", summary="(dtype float32)", inline=True)},
484 )
485 console = Console(record=True, width=80, file=io.StringIO(), force_jupyter=False)
486 console.print(report)
487 text = console.export_text()
488 assert "image: (dtype float32)" in text
489 assert "(Image)" not in text