Coverage for tests/test_describe.py: 100%
227 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-14 08:02 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-14 08:02 +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
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 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
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
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
142def test_repr_html_does_not_publish_in_jupyter() -> None:
143 """_repr_html_ returns HTML without also publishing it to the notebook.
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
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
164 assert published == []
165 assert "Interval" in html
168def test_mixin_derives_dunders_from_describe() -> None:
169 """DescribableMixin wires repr/str/html to _describe."""
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 )
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)
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
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
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__()
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
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
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()
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
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
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(")
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)
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
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"
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
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)"
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
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"})
349def test_describe_options_are_optional_for_implementations() -> None:
350 """An implementation may ignore options entirely and still describe."""
352 class Widget(DescribableMixin):
353 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
354 return Report(type_name="Widget", fields=[ReportField(label="size", value=5)])
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)"
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)
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))
400def test_to_repr_is_descriptive_when_nothing_feeds_it() -> None:
401 """A report with no repr-feeding fields gets the angle-bracket form.
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>"
414 # Without one, the type name alone.
415 assert Report(type_name="SkyProjection").to_repr() == "<SkyProjection>"
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)>"
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'))"
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.
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
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()
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