Coverage for python/lsst/images/describe.py: 48%
186 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 10:48 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 10:48 +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
28import re
29from collections.abc import Collection, Iterator
30from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
32from rich.console import Console
33from rich.table import Table
34from rich.text import Text
35from rich.tree import Tree
37if TYPE_CHECKING:
38 from rich.console import RenderableType
40_EMPHASIS_STYLES = {"emphasis": "italic", "strong": "bold"}
41"""`rich` style for each kind of emphasized span, keyed by the group
42`_EMPHASIS_PATTERN` captures it as.
44Weight and slant rather than color: emphasis marks a span of a line that is
45otherwise ordinary, and it has to survive a terminal or an export that has no
46color.
47"""
49_EMPHASIS_PATTERN = re.compile(r"\*\*(?P<strong>[^*]+)\*\*|\*(?P<emphasis>[^*]+)\*")
50"""Markdown-style delimiters around an emphasized span of a headline:
51``*emphasis*`` and ``**strong**``.
53Both delimiters of a pair are required, so a lone ``*`` in a name or a unit
54renders as itself.
55"""
58def _emphasized_spans(text: str) -> Iterator[tuple[str, str | None]]:
59 """Split a headline into its runs of plain and emphasized text.
61 Parameters
62 ----------
63 text
64 Headline that may carry ``*``-delimited spans.
66 Yields
67 ------
68 span : `str`
69 Run of text with its delimiters removed.
70 style : `str` or `None`
71 `rich` style for the run, or `None` where it is not emphasized.
72 """
73 position = 0
74 for match in _EMPHASIS_PATTERN.finditer(text):
75 if plain := text[position : match.start()]:
76 yield plain, None
77 kind = "strong" if match.group("strong") is not None else "emphasis"
78 yield match.group(kind), _EMPHASIS_STYLES[kind]
79 position = match.end()
80 if plain := text[position:]:
81 yield plain, None
84def _strip_emphasis(text: str) -> str:
85 """Return a headline with its emphasis delimiters removed.
87 Parameters
88 ----------
89 text
90 Headline that may carry ``*``-delimited spans.
92 Returns
93 -------
94 text : `str`
95 The headline as plain text.
96 """
97 return "".join(span for span, _ in _emphasized_spans(text))
100def _render_emphasis(text: str) -> Text:
101 """Return a headline with its ``*``-delimited spans emphasized.
103 Parameters
104 ----------
105 text
106 Headline that may carry ``*``-delimited spans.
108 Returns
109 -------
110 text : `rich.text.Text`
111 The headline with the delimiters removed and the spans they marked
112 styled.
113 """
114 result = Text()
115 for span, style in _emphasized_spans(text):
116 result.append(span, style=style)
117 return result
120class FieldRole(enum.Enum):
121 """Where a report field appears: in ``repr``, in the expanded report, or
122 both.
123 """
125 ARG = "arg"
126 """A constructor argument that also informs; reproduced in ``repr`` and
127 shown in the expanded report.
128 """
130 REPR_ONLY = "repr_only"
131 """A constructor argument that ``repr`` needs to round-trip but that would
132 duplicate a child or a more readable field in the expanded report.
133 """
135 DERIVED = "derived"
136 """An informational or computed value; shown in the expanded report but
137 never reproduced in ``repr``.
138 """
140 @property
141 def in_repr(self) -> bool:
142 """Whether fields with this role feed ``repr`` and ``str`` (`bool`)."""
143 return self is not FieldRole.DERIVED
145 @property
146 def in_display(self) -> bool:
147 """Whether fields with this role appear in the expanded report
148 (`bool`).
149 """
150 return self is not FieldRole.REPR_ONLY
153@dataclasses.dataclass(frozen=True)
154class DescribeOptions:
155 """Rendering options threaded through a tree of `Report` objects.
157 Options that apply to a single type only (such as the ``bbox`` understood
158 by `~lsst.images.SkyProjection`) are explicit keyword arguments on that
159 type's ``_describe`` instead of members here, so that this class stays
160 meaningful at every level of a report tree.
161 """
163 brief: bool = False
164 """Whether to build only what ``repr`` and ``str`` read.
166 When `True`, implementations skip children, tables, and any derived value
167 that is expensive to compute. Which *fields* feed ``repr`` is governed by
168 `FieldRole`, not by this flag.
169 """
171 detail: bool = False
172 """Whether to include extras that are too expensive for a default report,
173 such as per-plane set-pixel counts that must scan the mask.
174 """
176 exclude: frozenset[str] = frozenset()
177 """Names of report elements a composite has already shown once at the top
178 level, and which its children should therefore omit.
179 """
181 def for_child(self, *exclude: str) -> DescribeOptions:
182 """Return the options a child report should be built with.
184 Parameters
185 ----------
186 *exclude
187 Names of report elements the child should omit because the parent
188 displays them once at the top level. Replaces, rather than adds
189 to, any `exclude` set on ``self``.
191 Returns
192 -------
193 options : `DescribeOptions`
194 Options carrying this object's `brief` and `detail` settings.
195 """
196 return dataclasses.replace(self, exclude=frozenset(exclude))
199@dataclasses.dataclass(frozen=True)
200class ReportField:
201 """A single labeled value in a `Report`."""
203 label: str
204 """Human-readable label for the value."""
206 value: Any
207 """Display value for the field."""
209 unit: str | None = None
210 """Unit rendered after the value, if any."""
212 repr_value: str | None = None
213 """Eval-ish fragment used in ``repr``; defaults to ``repr(value)``."""
215 role: FieldRole = FieldRole.ARG
216 """Where this field appears (`FieldRole`)."""
218 positional: bool = False
219 """If `True`, ``repr`` emits the value positionally (no ``label=``)."""
222@dataclasses.dataclass(frozen=True)
223class ReportValueGroup:
224 """Short labeled values packed several to a rendered line.
226 Use this where a report has many small scalars that would each be
227 uninformative on a line of their own. The renderer joins them and lets
228 them wrap to the available width, so long values (a list, say) belong in
229 a `ReportField` instead, where they get a line to themselves.
230 """
232 values: list[tuple[str, Any]]
233 """Ordered ``(label, value)`` pairs."""
235 role: FieldRole = FieldRole.DERIVED
236 """Value groups never feed ``repr``; always `FieldRole.DERIVED`."""
239@dataclasses.dataclass(frozen=True)
240class ReportTable:
241 """Homogeneous columnar data rendered as an aligned table."""
243 title: str | None
244 """Title shown above the table, if any."""
246 columns: list[str]
247 """Header row labels."""
249 rows: list[list[Any]]
250 """One list of cell values per row, aligned to ``columns``."""
252 role: FieldRole = FieldRole.DERIVED
253 """Tables never feed ``repr``; always `FieldRole.DERIVED`."""
256@dataclasses.dataclass
257class Report:
258 """A renderer-agnostic description of an object."""
260 type_name: str
261 """Name of the described type."""
263 title: str | None = None
264 """Optional headline shown above the fields."""
266 summary: str | None = None
267 """Optional one-line hint used by ``__str__``."""
269 fields: list[ReportField] = dataclasses.field(default_factory=list)
270 """Ordered labeled values."""
272 tables: list[ReportTable] = dataclasses.field(default_factory=list)
273 """Ordered tables of columnar data."""
275 value_groups: list[ReportValueGroup] = dataclasses.field(default_factory=list)
276 """Ordered groups of short values packed several to a line."""
278 children: dict[str, Report] = dataclasses.field(default_factory=dict)
279 """Named nested sub-reports."""
281 inline: bool = False
282 """If `True`, render as a single ``key: summary`` line when embedded as a
283 child of another report, instead of a nested branch.
284 """
286 emphasis_markup: bool = False
287 """Whether this report's headline marks spans markdown-style, as
288 ``*emphasis*`` and ``**strong**``.
290 The headline is the heading of this report's branch, or the single line it
291 renders as when `inline`. Marking a span rather than styling the whole
292 line keeps the eye off the routine part of it, and a headline may mark as
293 many spans as it needs. The delimiters are removed wherever the headline
294 is read as plain text, so ``str`` and ``repr`` never show them.
296 Emphasis never carries meaning on its own, since a plain-text export drops
297 it, so a marked span has to read as the point it is making. Reports leave
298 this `False` unless they mark something, so a stray ``*`` in a value is
299 never mistaken for a delimiter.
300 """
302 def to_repr(self) -> str:
303 """Return a ``repr`` string built from the fields whose role feeds
304 ``repr``.
306 Notes
307 -----
308 Reports with no such fields describe objects that cannot be rebuilt
309 from a string, such as those wrapping an AST mapping. Those get the
310 angle-bracket form Python uses for objects whose ``repr`` is
311 descriptive, rather than an empty call that would claim an
312 eval-ability they do not have.
313 """
314 parts: list[str] = []
315 for field in self.fields:
316 if not field.role.in_repr:
317 continue
318 value = field.repr_value if field.repr_value is not None else repr(field.value)
319 parts.append(value if field.positional else f"{field.label}={value}")
320 if not parts:
321 if self.summary is None:
322 return f"<{self.type_name}>"
323 summary = self.to_str()
324 # Some summaries already open with the type name, which reads
325 # naturally on its own; do not state it twice.
326 if summary.startswith(self.type_name):
327 return f"<{summary}>"
328 return f"<{self.type_name}: {summary}>"
329 return f"{self.type_name}({', '.join(parts)})"
331 def to_str(self) -> str:
332 """Return a compact one-line summary."""
333 marked = self._marked_str()
334 return _strip_emphasis(marked) if self.emphasis_markup else marked
336 def _marked_str(self) -> str:
337 """Return `to_str` with any emphasis delimiters left in place."""
338 if self.summary is not None:
339 return self.summary
340 args = [str(field.value) for field in self.fields if field.role.in_repr]
341 inner = ", ".join(args[:3])
342 return f"{self.type_name}({inner})"
344 def _field_line(self, field: ReportField) -> str:
345 """Return a ``label: value unit`` string for a field."""
346 text = f"{field.label}: {field.value}"
347 if field.unit is not None: 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true
348 text = f"{text} {field.unit}"
349 return text
351 def _as_table(self, table: ReportTable) -> Table:
352 """Convert a `ReportTable` to a `rich.table.Table`."""
353 rich_table = Table(
354 title=Text(table.title) if table.title is not None else None,
355 title_justify="left",
356 )
357 for column in table.columns:
358 rich_table.add_column(Text(column))
359 for row in table.rows:
360 rich_table.add_row(*(Text(str(cell)) for cell in row))
361 return rich_table
363 @property
364 def _heading(self) -> str:
365 """Text naming this report where it heads a tree (`str`)."""
366 return self.title if self.title is not None else self.type_name
368 def _headline(self, text: str) -> Text:
369 """Return this report's rendered headline.
371 Parameters
372 ----------
373 text
374 Composed headline, which the caller assembles: a report names
375 itself differently where it heads its own tree and where it is a
376 line within its parent's.
378 Returns
379 -------
380 headline : `rich.text.Text`
381 The text, with any marked spans emphasized.
382 """
383 return _render_emphasis(text) if self.emphasis_markup else Text(text)
385 def _as_tree(self, heading: str) -> Tree:
386 """Return a `rich.tree.Tree` for this report under the given heading.
388 Parameters
389 ----------
390 heading
391 Text to label the root of the tree with.
392 """
393 tree = Tree(self._headline(heading))
394 for field in self.fields:
395 if not field.role.in_display:
396 continue
397 tree.add(Text(self._field_line(field)))
398 for group in self.value_groups:
399 # A plain Text wraps to the available width, packing as many
400 # values onto each line as will fit.
401 tree.add(Text(" ".join(f"{label}={value}" for label, value in group.values)))
402 for table in self.tables:
403 tree.add(self._as_table(table))
404 for key, child in self.children.items():
405 if child.inline:
406 tree.add(child._headline(f"{key}: {child._marked_str()}"))
407 else:
408 # Name the child and what it is on one line, rather than
409 # spending a level and a line on each.
410 tree.add(child._as_tree(f"{key} ({child._heading})"))
411 return tree
413 def __rich__(self) -> Tree:
414 """Return a `rich.tree.Tree` describing this report."""
415 return self._as_tree(self._heading)
417 def _repr_html_(self) -> str:
418 """Return an HTML rendering produced by rich."""
419 # force_jupyter=False keeps console.print writing to the recording
420 # buffer; inside a notebook a Jupyter-aware console would instead
421 # publish the render itself, doubling the displayed output.
422 console = Console(record=True, width=100, file=io.StringIO(), force_jupyter=False)
423 console.print(self)
424 return console.export_html(inline_styles=True)
427# runtime_checkable supports the isinstance check in the "describe" command
428# line subcommand, which must decide whether an arbitrary deserialized object
429# can be described at all.
430@runtime_checkable
431class Describable(Protocol):
432 """An object that can produce a `Report` describing itself.
434 `describe` is the entry point callers use; `_describe` is the recursive
435 contract composites use to build child reports. They differ in signature:
436 `describe` spells its options out as keyword arguments for convenience,
437 while `_describe` takes the single `DescribeOptions` value that composites
438 thread down the tree.
439 """
441 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
442 """Return a `Report` describing this object.
444 Parameters
445 ----------
446 options : `DescribeOptions`, optional
447 Rendering options. Implementations ignore the members they have
448 no use for, so new members can be added without breaking them.
450 Returns
451 -------
452 report : `Report`
453 Report describing this object.
454 """
455 ...
457 def describe(self, *, brief: bool = False, detail: bool = False, exclude: Collection[str] = ()) -> Report:
458 """Return a `Report` describing this object.
460 Parameters
461 ----------
462 brief : `bool`, optional
463 Whether to build only what ``repr`` and ``str`` read.
464 detail : `bool`, optional
465 Whether to include extras that are too expensive for a default
466 report.
467 exclude : `~collections.abc.Collection` [`str`], optional
468 Names of report elements to omit.
470 Returns
471 -------
472 report : `Report`
473 Report describing this object.
474 """
475 ...
478class DescribableMixin:
479 """Mixin that wires repr, str, rich, and HTML rendering to `_describe`."""
481 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
482 """Return a `Report` describing this object.
484 Parameters
485 ----------
486 options : `DescribeOptions`, optional
487 Rendering options.
489 Returns
490 -------
491 report : `Report`
492 Report describing this object.
493 """
494 raise NotImplementedError()
496 def describe(self, *, brief: bool = False, detail: bool = False, exclude: Collection[str] = ()) -> Report:
497 """Return a `~lsst.images.Report` describing this object.
499 Parameters
500 ----------
501 brief : `bool`, optional
502 Whether to build only what ``repr`` and ``str`` read.
503 detail : `bool`, optional
504 Whether to include extras that are too expensive for a default
505 report, such as per-plane set-pixel counts.
506 exclude : `~collections.abc.Collection` [`str`], optional
507 Names of report elements to omit.
509 Returns
510 -------
511 report : `~lsst.images.Report`
512 Report describing this object.
513 """
514 return self._describe(DescribeOptions(brief=brief, detail=detail, exclude=frozenset(exclude)))
516 def __repr__(self) -> str:
517 return self._describe(DescribeOptions(brief=True)).to_repr()
519 def __str__(self) -> str:
520 return self._describe(DescribeOptions(brief=True)).to_str()
522 def _repr_html_(self) -> str:
523 return self._describe(DescribeOptions(detail=True))._repr_html_()
525 def __rich__(self) -> RenderableType:
526 return self._describe(DescribeOptions(detail=True)).__rich__()