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
« 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.
12from __future__ import annotations
14import contextlib
15import io
16import os
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
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
45 table = ReportTable(title="Axes", columns=["Axis", "Label"], rows=[[1, "RA"]])
46 assert table.role is FieldRole.DERIVED
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 == {}
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=...)"
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)"
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'))"
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)"
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)"
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
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
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
139def test_repr_html_does_not_publish_in_jupyter() -> None:
140 """_repr_html_ returns HTML without also publishing it to the notebook.
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
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
161 assert published == []
162 assert "Interval" in html
165def test_mixin_derives_dunders_from_describe() -> None:
166 """DescribableMixin wires repr/str/html to _describe."""
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 )
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)
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
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
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__()
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
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
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()
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
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
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(")
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)
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
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"
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
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)"
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
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"})
346def test_describe_options_are_optional_for_implementations() -> None:
347 """An implementation may ignore options entirely and still describe."""
349 class Widget(DescribableMixin):
350 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
351 return Report(type_name="Widget", fields=[ReportField(label="size", value=5)])
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)"
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)
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))
397def test_to_repr_is_descriptive_when_nothing_feeds_it() -> None:
398 """A report with no repr-feeding fields gets the angle-bracket form.
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>"
411 # Without one, the type name alone.
412 assert Report(type_name="SkyProjection").to_repr() == "<SkyProjection>"
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)>"
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'))"
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.
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
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()
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