Coverage for python/lsst/images/describe.py: 44%
156 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
14__all__ = (
15 "Describable",
16 "DescribableMixin",
17 "DescribeOptions",
18 "FieldRole",
19 "Report",
20 "ReportField",
21 "ReportTable",
22 "ReportValueGroup",
23)
25import dataclasses
26import enum
27import io
28from collections.abc import Collection
29from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
31from rich.console import Console
32from rich.table import Table
33from rich.text import Text
34from rich.tree import Tree
36if TYPE_CHECKING:
37 from rich.console import RenderableType
40class FieldRole(enum.Enum):
41 """Where a report field appears: in ``repr``, in the expanded report, or
42 both.
43 """
45 ARG = "arg"
46 """A constructor argument that also informs; reproduced in ``repr`` and
47 shown in the expanded report.
48 """
50 REPR_ONLY = "repr_only"
51 """A constructor argument that ``repr`` needs to round-trip but that would
52 duplicate a child or a more readable field in the expanded report.
53 """
55 DERIVED = "derived"
56 """An informational or computed value; shown in the expanded report but
57 never reproduced in ``repr``.
58 """
60 @property
61 def in_repr(self) -> bool:
62 """Whether fields with this role feed ``repr`` and ``str`` (`bool`)."""
63 return self is not FieldRole.DERIVED
65 @property
66 def in_display(self) -> bool:
67 """Whether fields with this role appear in the expanded report
68 (`bool`).
69 """
70 return self is not FieldRole.REPR_ONLY
73@dataclasses.dataclass(frozen=True)
74class DescribeOptions:
75 """Rendering options threaded through a tree of `Report` objects.
77 Options that apply to a single type only (such as the ``bbox`` understood
78 by `~lsst.images.SkyProjection`) are explicit keyword arguments on that
79 type's ``_describe`` instead of members here, so that this class stays
80 meaningful at every level of a report tree.
81 """
83 brief: bool = False
84 """Whether to build only what ``repr`` and ``str`` read.
86 When `True`, implementations skip children, tables, and any derived value
87 that is expensive to compute. Which *fields* feed ``repr`` is governed by
88 `FieldRole`, not by this flag.
89 """
91 detail: bool = False
92 """Whether to include extras that are too expensive for a default report,
93 such as per-plane set-pixel counts that must scan the mask.
94 """
96 exclude: frozenset[str] = frozenset()
97 """Names of report elements a composite has already shown once at the top
98 level, and which its children should therefore omit.
99 """
101 def for_child(self, *exclude: str) -> DescribeOptions:
102 """Return the options a child report should be built with.
104 Parameters
105 ----------
106 *exclude
107 Names of report elements the child should omit because the parent
108 displays them once at the top level. Replaces, rather than adds
109 to, any `exclude` set on ``self``.
111 Returns
112 -------
113 options : `DescribeOptions`
114 Options carrying this object's `brief` and `detail` settings.
115 """
116 return dataclasses.replace(self, exclude=frozenset(exclude))
119@dataclasses.dataclass(frozen=True)
120class ReportField:
121 """A single labeled value in a `Report`."""
123 label: str
124 """Human-readable label for the value."""
126 value: Any
127 """Display value for the field."""
129 unit: str | None = None
130 """Unit rendered after the value, if any."""
132 repr_value: str | None = None
133 """Eval-ish fragment used in ``repr``; defaults to ``repr(value)``."""
135 role: FieldRole = FieldRole.ARG
136 """Where this field appears (`FieldRole`)."""
138 positional: bool = False
139 """If `True`, ``repr`` emits the value positionally (no ``label=``)."""
142@dataclasses.dataclass(frozen=True)
143class ReportValueGroup:
144 """Short labeled values packed several to a rendered line.
146 Use this where a report has many small scalars that would each be
147 uninformative on a line of their own. The renderer joins them and lets
148 them wrap to the available width, so long values (a list, say) belong in
149 a `ReportField` instead, where they get a line to themselves.
150 """
152 values: list[tuple[str, Any]]
153 """Ordered ``(label, value)`` pairs."""
155 role: FieldRole = FieldRole.DERIVED
156 """Value groups never feed ``repr``; always `FieldRole.DERIVED`."""
159@dataclasses.dataclass(frozen=True)
160class ReportTable:
161 """Homogeneous columnar data rendered as an aligned table."""
163 title: str | None
164 """Title shown above the table, if any."""
166 columns: list[str]
167 """Header row labels."""
169 rows: list[list[Any]]
170 """One list of cell values per row, aligned to ``columns``."""
172 role: FieldRole = FieldRole.DERIVED
173 """Tables never feed ``repr``; always `FieldRole.DERIVED`."""
176@dataclasses.dataclass
177class Report:
178 """A renderer-agnostic description of an object."""
180 type_name: str
181 """Name of the described type."""
183 title: str | None = None
184 """Optional headline shown above the fields."""
186 summary: str | None = None
187 """Optional one-line hint used by ``__str__``."""
189 fields: list[ReportField] = dataclasses.field(default_factory=list)
190 """Ordered labeled values."""
192 tables: list[ReportTable] = dataclasses.field(default_factory=list)
193 """Ordered tables of columnar data."""
195 value_groups: list[ReportValueGroup] = dataclasses.field(default_factory=list)
196 """Ordered groups of short values packed several to a line."""
198 children: dict[str, Report] = dataclasses.field(default_factory=dict)
199 """Named nested sub-reports."""
201 inline: bool = False
202 """If `True`, render as a single ``key: summary`` line when embedded as a
203 child of another report, instead of a nested branch.
204 """
206 def to_repr(self) -> str:
207 """Return a ``repr`` string built from the fields whose role feeds
208 ``repr``.
210 Notes
211 -----
212 Reports with no such fields describe objects that cannot be rebuilt
213 from a string, such as those wrapping an AST mapping. Those get the
214 angle-bracket form Python uses for objects whose ``repr`` is
215 descriptive, rather than an empty call that would claim an
216 eval-ability they do not have.
217 """
218 parts: list[str] = []
219 for field in self.fields:
220 if not field.role.in_repr:
221 continue
222 value = field.repr_value if field.repr_value is not None else repr(field.value)
223 parts.append(value if field.positional else f"{field.label}={value}")
224 if not parts:
225 if self.summary is None:
226 return f"<{self.type_name}>"
227 # Some summaries already open with the type name, which reads
228 # naturally on its own; do not state it twice.
229 if self.summary.startswith(self.type_name):
230 return f"<{self.summary}>"
231 return f"<{self.type_name}: {self.summary}>"
232 return f"{self.type_name}({', '.join(parts)})"
234 def to_str(self) -> str:
235 """Return a compact one-line summary."""
236 if self.summary is not None:
237 return self.summary
238 args = [str(field.value) for field in self.fields if field.role.in_repr]
239 inner = ", ".join(args[:3])
240 return f"{self.type_name}({inner})"
242 def _field_line(self, field: ReportField) -> str:
243 """Return a ``label: value unit`` string for a field."""
244 text = f"{field.label}: {field.value}"
245 if field.unit is not None: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true
246 text = f"{text} {field.unit}"
247 return text
249 def _as_table(self, table: ReportTable) -> Table:
250 """Convert a `ReportTable` to a `rich.table.Table`."""
251 rich_table = Table(
252 title=Text(table.title) if table.title is not None else None,
253 title_justify="left",
254 )
255 for column in table.columns:
256 rich_table.add_column(Text(column))
257 for row in table.rows:
258 rich_table.add_row(*(Text(str(cell)) for cell in row))
259 return rich_table
261 @property
262 def _heading(self) -> str:
263 """Text naming this report where it heads a tree (`str`)."""
264 return self.title if self.title is not None else self.type_name
266 def _as_tree(self, heading: str) -> Tree:
267 """Return a `rich.tree.Tree` for this report under the given heading.
269 Parameters
270 ----------
271 heading
272 Text to label the root of the tree with.
273 """
274 tree = Tree(Text(heading))
275 for field in self.fields:
276 if not field.role.in_display:
277 continue
278 tree.add(Text(self._field_line(field)))
279 for group in self.value_groups:
280 # A plain Text wraps to the available width, packing as many
281 # values onto each line as will fit.
282 tree.add(Text(" ".join(f"{label}={value}" for label, value in group.values)))
283 for table in self.tables:
284 tree.add(self._as_table(table))
285 for key, child in self.children.items():
286 if child.inline:
287 tree.add(Text(f"{key}: {child.to_str()}"))
288 else:
289 # Name the child and what it is on one line, rather than
290 # spending a level and a line on each.
291 tree.add(child._as_tree(f"{key} ({child._heading})"))
292 return tree
294 def __rich__(self) -> Tree:
295 """Return a `rich.tree.Tree` describing this report."""
296 return self._as_tree(self._heading)
298 def _repr_html_(self) -> str:
299 """Return an HTML rendering produced by rich."""
300 # force_jupyter=False keeps console.print writing to the recording
301 # buffer; inside a notebook a Jupyter-aware console would instead
302 # publish the render itself, doubling the displayed output.
303 console = Console(record=True, width=100, file=io.StringIO(), force_jupyter=False)
304 console.print(self)
305 return console.export_html(inline_styles=True)
308# runtime_checkable supports the isinstance check in the "describe" command
309# line subcommand, which must decide whether an arbitrary deserialized object
310# can be described at all.
311@runtime_checkable
312class Describable(Protocol):
313 """An object that can produce a `Report` describing itself.
315 `describe` is the entry point callers use; `_describe` is the recursive
316 contract composites use to build child reports. They differ in signature:
317 `describe` spells its options out as keyword arguments for convenience,
318 while `_describe` takes the single `DescribeOptions` value that composites
319 thread down the tree.
320 """
322 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
323 """Return a `Report` describing this object.
325 Parameters
326 ----------
327 options : `DescribeOptions`, optional
328 Rendering options. Implementations ignore the members they have
329 no use for, so new members can be added without breaking them.
331 Returns
332 -------
333 report : `Report`
334 Report describing this object.
335 """
336 ...
338 def describe(self, *, brief: bool = False, detail: bool = False, exclude: Collection[str] = ()) -> Report:
339 """Return a `Report` describing this object.
341 Parameters
342 ----------
343 brief : `bool`, optional
344 Whether to build only what ``repr`` and ``str`` read.
345 detail : `bool`, optional
346 Whether to include extras that are too expensive for a default
347 report.
348 exclude : `~collections.abc.Collection` [`str`], optional
349 Names of report elements to omit.
351 Returns
352 -------
353 report : `Report`
354 Report describing this object.
355 """
356 ...
359class DescribableMixin:
360 """Mixin that wires repr, str, rich, and HTML rendering to `_describe`."""
362 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
363 """Return a `Report` describing this object.
365 Parameters
366 ----------
367 options : `DescribeOptions`, optional
368 Rendering options.
370 Returns
371 -------
372 report : `Report`
373 Report describing this object.
374 """
375 raise NotImplementedError()
377 def describe(self, *, brief: bool = False, detail: bool = False, exclude: Collection[str] = ()) -> Report:
378 """Return a `~lsst.images.Report` describing this object.
380 Parameters
381 ----------
382 brief : `bool`, optional
383 Whether to build only what ``repr`` and ``str`` read.
384 detail : `bool`, optional
385 Whether to include extras that are too expensive for a default
386 report, such as per-plane set-pixel counts.
387 exclude : `~collections.abc.Collection` [`str`], optional
388 Names of report elements to omit.
390 Returns
391 -------
392 report : `~lsst.images.Report`
393 Report describing this object.
394 """
395 return self._describe(DescribeOptions(brief=brief, detail=detail, exclude=frozenset(exclude)))
397 def __repr__(self) -> str:
398 return self._describe(DescribeOptions(brief=True)).to_repr()
400 def __str__(self) -> str:
401 return self._describe(DescribeOptions(brief=True)).to_str()
403 def _repr_html_(self) -> str:
404 return self._describe(DescribeOptions(detail=True))._repr_html_()
406 def __rich__(self) -> RenderableType:
407 return self._describe(DescribeOptions(detail=True)).__rich__()