Coverage for tests/test_describe.py: 100%
267 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-24 09:07 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-24 09:07 +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._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
38FIXTURE_DIR = Path(__file__).parent / "data" / "schemas"
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
49 table = ReportTable(title="Axes", columns=["Axis", "Label"], rows=[[1, "RA"]])
50 assert table.role is FieldRole.DERIVED
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 == {}
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=...)"
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)"
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'))"
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)"
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)"
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
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
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
148def test_repr_html_does_not_publish_in_jupyter() -> None:
149 """_repr_html_ returns HTML without also publishing it to the notebook.
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
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
170 assert published == []
171 assert "Interval" in html
174def test_mixin_derives_dunders_from_describe() -> None:
175 """DescribableMixin wires repr/str/html to _describe."""
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 )
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)
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
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
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__()
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
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
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()
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
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
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(")
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)
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
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"
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
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)"
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
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"})
358def test_describe_options_are_optional_for_implementations() -> None:
359 """An implementation may ignore options entirely and still describe."""
361 class Widget(DescribableMixin):
362 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
363 return Report(type_name="Widget", fields=[ReportField(label="size", value=5)])
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)"
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)
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))
409def test_to_repr_is_descriptive_when_nothing_feeds_it() -> None:
410 """A report with no repr-feeding fields gets the angle-bracket form.
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>"
423 # Without one, the type name alone.
424 assert Report(type_name="SkyProjection").to_repr() == "<SkyProjection>"
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)>"
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'))"
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.
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
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()
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
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>"
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"
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