Coverage for python/lsst/images/describe.py: 48%

186 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-26 02:37 -0700

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. 

11 

12from __future__ import annotations 

13 

14__all__ = ( 

15 "Describable", 

16 "DescribableMixin", 

17 "DescribeOptions", 

18 "FieldRole", 

19 "Report", 

20 "ReportField", 

21 "ReportTable", 

22 "ReportValueGroup", 

23) 

24 

25import dataclasses 

26import enum 

27import io 

28import re 

29from collections.abc import Collection, Iterator 

30from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable 

31 

32from rich.console import Console 

33from rich.table import Table 

34from rich.text import Text 

35from rich.tree import Tree 

36 

37if TYPE_CHECKING: 

38 from rich.console import RenderableType 

39 

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. 

43 

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""" 

48 

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**``. 

52 

53Both delimiters of a pair are required, so a lone ``*`` in a name or a unit 

54renders as itself. 

55""" 

56 

57 

58def _emphasized_spans(text: str) -> Iterator[tuple[str, str | None]]: 

59 """Split a headline into its runs of plain and emphasized text. 

60 

61 Parameters 

62 ---------- 

63 text 

64 Headline that may carry ``*``-delimited spans. 

65 

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 

82 

83 

84def _strip_emphasis(text: str) -> str: 

85 """Return a headline with its emphasis delimiters removed. 

86 

87 Parameters 

88 ---------- 

89 text 

90 Headline that may carry ``*``-delimited spans. 

91 

92 Returns 

93 ------- 

94 text : `str` 

95 The headline as plain text. 

96 """ 

97 return "".join(span for span, _ in _emphasized_spans(text)) 

98 

99 

100def _render_emphasis(text: str) -> Text: 

101 """Return a headline with its ``*``-delimited spans emphasized. 

102 

103 Parameters 

104 ---------- 

105 text 

106 Headline that may carry ``*``-delimited spans. 

107 

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 

118 

119 

120class FieldRole(enum.Enum): 

121 """Where a report field appears: in ``repr``, in the expanded report, or 

122 both. 

123 """ 

124 

125 ARG = "arg" 

126 """A constructor argument that also informs; reproduced in ``repr`` and 

127 shown in the expanded report. 

128 """ 

129 

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 """ 

134 

135 DERIVED = "derived" 

136 """An informational or computed value; shown in the expanded report but 

137 never reproduced in ``repr``. 

138 """ 

139 

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 

144 

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 

151 

152 

153@dataclasses.dataclass(frozen=True) 

154class DescribeOptions: 

155 """Rendering options threaded through a tree of `Report` objects. 

156 

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 """ 

162 

163 brief: bool = False 

164 """Whether to build only what ``repr`` and ``str`` read. 

165 

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 """ 

170 

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 """ 

175 

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 """ 

180 

181 def for_child(self, *exclude: str) -> DescribeOptions: 

182 """Return the options a child report should be built with. 

183 

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``. 

190 

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)) 

197 

198 

199@dataclasses.dataclass(frozen=True) 

200class ReportField: 

201 """A single labeled value in a `Report`.""" 

202 

203 label: str 

204 """Human-readable label for the value.""" 

205 

206 value: Any 

207 """Display value for the field.""" 

208 

209 unit: str | None = None 

210 """Unit rendered after the value, if any.""" 

211 

212 repr_value: str | None = None 

213 """Eval-ish fragment used in ``repr``; defaults to ``repr(value)``.""" 

214 

215 role: FieldRole = FieldRole.ARG 

216 """Where this field appears (`FieldRole`).""" 

217 

218 positional: bool = False 

219 """If `True`, ``repr`` emits the value positionally (no ``label=``).""" 

220 

221 

222@dataclasses.dataclass(frozen=True) 

223class ReportValueGroup: 

224 """Short labeled values packed several to a rendered line. 

225 

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 """ 

231 

232 values: list[tuple[str, Any]] 

233 """Ordered ``(label, value)`` pairs.""" 

234 

235 role: FieldRole = FieldRole.DERIVED 

236 """Value groups never feed ``repr``; always `FieldRole.DERIVED`.""" 

237 

238 

239@dataclasses.dataclass(frozen=True) 

240class ReportTable: 

241 """Homogeneous columnar data rendered as an aligned table.""" 

242 

243 title: str | None 

244 """Title shown above the table, if any.""" 

245 

246 columns: list[str] 

247 """Header row labels.""" 

248 

249 rows: list[list[Any]] 

250 """One list of cell values per row, aligned to ``columns``.""" 

251 

252 role: FieldRole = FieldRole.DERIVED 

253 """Tables never feed ``repr``; always `FieldRole.DERIVED`.""" 

254 

255 

256@dataclasses.dataclass 

257class Report: 

258 """A renderer-agnostic description of an object.""" 

259 

260 type_name: str 

261 """Name of the described type.""" 

262 

263 title: str | None = None 

264 """Optional headline shown above the fields.""" 

265 

266 summary: str | None = None 

267 """Optional one-line hint used by ``__str__``.""" 

268 

269 fields: list[ReportField] = dataclasses.field(default_factory=list) 

270 """Ordered labeled values.""" 

271 

272 tables: list[ReportTable] = dataclasses.field(default_factory=list) 

273 """Ordered tables of columnar data.""" 

274 

275 value_groups: list[ReportValueGroup] = dataclasses.field(default_factory=list) 

276 """Ordered groups of short values packed several to a line.""" 

277 

278 children: dict[str, Report] = dataclasses.field(default_factory=dict) 

279 """Named nested sub-reports.""" 

280 

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 """ 

285 

286 emphasis_markup: bool = False 

287 """Whether this report's headline marks spans markdown-style, as 

288 ``*emphasis*`` and ``**strong**``. 

289 

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. 

295 

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 """ 

301 

302 def to_repr(self) -> str: 

303 """Return a ``repr`` string built from the fields whose role feeds 

304 ``repr``. 

305 

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)})" 

330 

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 

335 

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})" 

343 

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 

350 

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 

362 

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 

367 

368 def _headline(self, text: str) -> Text: 

369 """Return this report's rendered headline. 

370 

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. 

377 

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) 

384 

385 def _as_tree(self, heading: str) -> Tree: 

386 """Return a `rich.tree.Tree` for this report under the given heading. 

387 

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 

412 

413 def __rich__(self) -> Tree: 

414 """Return a `rich.tree.Tree` describing this report.""" 

415 return self._as_tree(self._heading) 

416 

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) 

425 

426 

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. 

433 

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 """ 

440 

441 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report: 

442 """Return a `Report` describing this object. 

443 

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. 

449 

450 Returns 

451 ------- 

452 report : `Report` 

453 Report describing this object. 

454 """ 

455 ... 

456 

457 def describe(self, *, brief: bool = False, detail: bool = False, exclude: Collection[str] = ()) -> Report: 

458 """Return a `Report` describing this object. 

459 

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. 

469 

470 Returns 

471 ------- 

472 report : `Report` 

473 Report describing this object. 

474 """ 

475 ... 

476 

477 

478class DescribableMixin: 

479 """Mixin that wires repr, str, rich, and HTML rendering to `_describe`.""" 

480 

481 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report: 

482 """Return a `Report` describing this object. 

483 

484 Parameters 

485 ---------- 

486 options : `DescribeOptions`, optional 

487 Rendering options. 

488 

489 Returns 

490 ------- 

491 report : `Report` 

492 Report describing this object. 

493 """ 

494 raise NotImplementedError() 

495 

496 def describe(self, *, brief: bool = False, detail: bool = False, exclude: Collection[str] = ()) -> Report: 

497 """Return a `~lsst.images.Report` describing this object. 

498 

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. 

508 

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))) 

515 

516 def __repr__(self) -> str: 

517 return self._describe(DescribeOptions(brief=True)).to_repr() 

518 

519 def __str__(self) -> str: 

520 return self._describe(DescribeOptions(brief=True)).to_str() 

521 

522 def _repr_html_(self) -> str: 

523 return self._describe(DescribeOptions(detail=True))._repr_html_() 

524 

525 def __rich__(self) -> RenderableType: 

526 return self._describe(DescribeOptions(detail=True)).__rich__()