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

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 

28from collections.abc import Collection 

29from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable 

30 

31from rich.console import Console 

32from rich.table import Table 

33from rich.text import Text 

34from rich.tree import Tree 

35 

36if TYPE_CHECKING: 

37 from rich.console import RenderableType 

38 

39 

40class FieldRole(enum.Enum): 

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

42 both. 

43 """ 

44 

45 ARG = "arg" 

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

47 shown in the expanded report. 

48 """ 

49 

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

54 

55 DERIVED = "derived" 

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

57 never reproduced in ``repr``. 

58 """ 

59 

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 

64 

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 

71 

72 

73@dataclasses.dataclass(frozen=True) 

74class DescribeOptions: 

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

76 

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

82 

83 brief: bool = False 

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

85 

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

90 

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

95 

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

100 

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

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

103 

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

110 

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

117 

118 

119@dataclasses.dataclass(frozen=True) 

120class ReportField: 

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

122 

123 label: str 

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

125 

126 value: Any 

127 """Display value for the field.""" 

128 

129 unit: str | None = None 

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

131 

132 repr_value: str | None = None 

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

134 

135 role: FieldRole = FieldRole.ARG 

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

137 

138 positional: bool = False 

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

140 

141 

142@dataclasses.dataclass(frozen=True) 

143class ReportValueGroup: 

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

145 

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

151 

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

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

154 

155 role: FieldRole = FieldRole.DERIVED 

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

157 

158 

159@dataclasses.dataclass(frozen=True) 

160class ReportTable: 

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

162 

163 title: str | None 

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

165 

166 columns: list[str] 

167 """Header row labels.""" 

168 

169 rows: list[list[Any]] 

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

171 

172 role: FieldRole = FieldRole.DERIVED 

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

174 

175 

176@dataclasses.dataclass 

177class Report: 

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

179 

180 type_name: str 

181 """Name of the described type.""" 

182 

183 title: str | None = None 

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

185 

186 summary: str | None = None 

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

188 

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

190 """Ordered labeled values.""" 

191 

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

193 """Ordered tables of columnar data.""" 

194 

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

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

197 

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

199 """Named nested sub-reports.""" 

200 

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

205 

206 def to_repr(self) -> str: 

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

208 ``repr``. 

209 

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

233 

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

241 

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 

248 

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 

260 

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 

265 

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

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

268 

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 

293 

294 def __rich__(self) -> Tree: 

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

296 return self._as_tree(self._heading) 

297 

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) 

306 

307 

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. 

314 

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

321 

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

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

324 

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. 

330 

331 Returns 

332 ------- 

333 report : `Report` 

334 Report describing this object. 

335 """ 

336 ... 

337 

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

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

340 

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. 

350 

351 Returns 

352 ------- 

353 report : `Report` 

354 Report describing this object. 

355 """ 

356 ... 

357 

358 

359class DescribableMixin: 

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

361 

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

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

364 

365 Parameters 

366 ---------- 

367 options : `DescribeOptions`, optional 

368 Rendering options. 

369 

370 Returns 

371 ------- 

372 report : `Report` 

373 Report describing this object. 

374 """ 

375 raise NotImplementedError() 

376 

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

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

379 

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. 

389 

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

396 

397 def __repr__(self) -> str: 

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

399 

400 def __str__(self) -> str: 

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

402 

403 def _repr_html_(self) -> str: 

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

405 

406 def __rich__(self) -> RenderableType: 

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