Coverage for jsonschema_diff/table_render.py: 99%

107 statements  

« prev     ^ index     » next       coverage.py v7.10.5, created at 2025-08-25 07:00 +0000

1"""Clean, modular implementation of rich-based legend tables (v2). 

2 

3Changes v2 

4----------- 

5* **Robust renderable detection** – supports returning any Rich renderable 

6 (Text, Panel, Table, etc.) directly from a column‑processor. 

7* ANSI bleed fixed: we never coerce unknown objects to `str`; if value 

8 isn't recognised we call `repr()` (safe, no control codes). 

9* Lists can mix str **and** Rich renderables – all rendered in a nested 

10 grid with rules between blocks. 

11* Helper `ColumnConfig.wrap=False` to suppress cell padding when 

12 renderable already has its own framing (e.g. Panel). 

13* Ratio now respected (outer `Table(expand=True, width=table_width)`), 

14 still no `max_width`. 

15""" 

16 

17from __future__ import annotations 

18 

19from dataclasses import dataclass 

20from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, Mapping, Sequence, Union, cast 

21 

22if TYPE_CHECKING: 

23 from .core.parameter_base import Compare 

24 

25from rich import box 

26from rich.console import Console, RenderableType 

27from rich.padding import Padding 

28from rich.rule import Rule 

29from rich.table import Table 

30 

31StrOrRenderable = Union[str, RenderableType] 

32Processor = Callable[..., StrOrRenderable | list[StrOrRenderable]] 

33 

34 

35def _is_rich_renderable(obj: Any) -> bool: 

36 """Heuristic: object implements Rich render protocol.""" 

37 return hasattr(obj, "__rich_console__") or hasattr(obj, "__rich_measure__") 

38 

39 

40@dataclass(slots=True) 

41class ColumnConfig: 

42 name: str 

43 header: str | None = None 

44 ratio: float | None = None 

45 justify: str = "left" 

46 no_wrap: bool = False 

47 wrap: bool = True # add Padding around the cell 

48 processor: Processor | None = None 

49 

50 def header_text(self) -> str: 

51 return self.header or self.name 

52 

53 

54@dataclass(slots=True) 

55class Cell: 

56 """Display‑ready cell.""" 

57 

58 value: RenderableType 

59 pad: bool = True # if False – value already framed (Panel/Table) 

60 

61 def renderable(self) -> RenderableType: 

62 if self.pad: 

63 return Padding(self.value, (0, 1)) 

64 return self.value 

65 

66 

67class LegendRenderer: 

68 """Facade to build a table from legend classes.""" 

69 

70 def __init__( 

71 self, 

72 columns: Sequence[ColumnConfig], 

73 *, 

74 box_style: box.Box = box.SQUARE_DOUBLE_HEAD, 

75 header_style: str = "bold", 

76 table_width: int | None = None, 

77 show_outer_lines: bool = True, 

78 default_overflow: str = "fold", 

79 ) -> None: 

80 self.columns = list(columns) 

81 self.box_style = box_style 

82 self.header_style = header_style 

83 self.table_width = table_width 

84 self.show_outer_lines = show_outer_lines 

85 self.default_overflow = default_overflow 

86 

87 # ------------------------------------------------------------------ 

88 # Public API 

89 # ------------------------------------------------------------------ 

90 

91 def rich_render(self, legend_classes: Iterable[type["Compare"]]) -> Table: 

92 legends = [cls.legend() for cls in legend_classes] 

93 self._validate_legends(legends) 

94 

95 rows: list[list[Cell]] = [self._build_row(legend) for legend in legends] 

96 return self._build_table(rows) 

97 

98 def render(self, legend_classes: Iterable[type["Compare"]]) -> str: 

99 table = self.rich_render(legend_classes) 

100 

101 # Use a throw‑away Console so we don't affect the caller's Console config 

102 console = Console( 

103 force_terminal=True, # ensure ANSI codes even when not attached to tty 

104 color_system="truecolor", 

105 width=self.table_width, # avoid unwanted wrapping 

106 legacy_windows=False, 

107 ) 

108 

109 with console.capture() as cap: 

110 console.print(table, end="") # prevent extra newline 

111 return cap.get() 

112 

113 # ------------------------------------------------------------------ 

114 # Internals 

115 # ------------------------------------------------------------------ 

116 

117 def _validate_legends(self, legends: Sequence[Mapping[str, Any]]) -> None: 

118 required = {c.name for c in self.columns} 

119 for i, legend in enumerate(legends): 

120 missing = required - legend.keys() 

121 if missing: 

122 raise KeyError(f"Legend #{i} missing keys: {', '.join(missing)}") 

123 

124 # ---------- building ------------------------------------------------ 

125 

126 def _build_row(self, legend: Mapping[str, Any]) -> list[Cell]: 

127 row: list[Cell] = [] 

128 for col in self.columns: 

129 raw = legend.get(col.name, "") 

130 processed = self._apply_processor(raw, col.processor) 

131 cell = self._make_cell(processed, pad=col.wrap, justify=col.justify) 

132 row.append(cell) 

133 return row 

134 

135 def _apply_processor(self, value: Any, processor: Processor | None) -> Any: 

136 if processor is None: 

137 return value 

138 if value in (None, "", [], ()): 

139 return value 

140 if isinstance(value, dict): 

141 return processor(**value) 

142 if isinstance(value, (list, tuple)): 

143 processed: list[Any] = [] 

144 for item in value: 

145 if isinstance(item, dict): 

146 processed.append(processor(**item)) 

147 elif isinstance(item, (list, tuple)): 

148 processed.append(processor(*item)) 

149 else: 

150 processed.append(processor(item)) 

151 return processed 

152 return processor(value) 

153 

154 def _make_cell(self, data: Any, *, pad: bool, justify: str = "left") -> Cell: 

155 # Direct rich renderable 

156 if _is_rich_renderable(data): 

157 return Cell(data, pad=pad) 

158 

159 # Primitive str / None 

160 if data is None or isinstance(data, str): 

161 return Cell("" if data is None else data, pad=pad) 

162 

163 # list / tuple – may mix str & renderables 

164 if isinstance(data, (list, tuple)): 

165 sub = Table.grid(expand=True, padding=(0, 0)) 

166 sub.add_column( 

167 ratio=1, 

168 justify=cast(Literal["default", "left", "center", "right", "full"], justify), 

169 overflow="fold", 

170 ) 

171 first = True 

172 for item in data: 

173 if not first: 

174 sub.add_row(Rule(style="none", characters="─")) 

175 sub.add_row(item if _is_rich_renderable(item) else str(item)) 

176 first = False 

177 return Cell(sub, pad=False) # already framed 

178 

179 # Fallback – safe repr (no ANSI leak) 

180 return Cell(repr(data), pad=pad) 

181 

182 def _build_table(self, rows: Sequence[Sequence[Cell]]) -> Table: 

183 tbl = Table( 

184 show_header=True, 

185 header_style=self.header_style, 

186 box=self.box_style, 

187 padding=(0, 0), 

188 show_lines=self.show_outer_lines, 

189 expand=True, 

190 width=self.table_width, 

191 ) 

192 for col in self.columns: 

193 tbl.add_column( 

194 col.header_text(), 

195 justify=cast(Literal["default", "left", "center", "right", "full"], col.justify), 

196 ratio=None if col.ratio is None else int(col.ratio), 

197 no_wrap=col.no_wrap, 

198 overflow=cast(Literal["fold", "crop", "ellipsis", "ignore"], self.default_overflow), 

199 ) 

200 

201 for r in rows: 

202 tbl.add_row(*[c.renderable() for c in r]) 

203 return tbl 

204 

205 

206# --------------------------------------------------------------------------- 

207# Convenience factory 

208# --------------------------------------------------------------------------- 

209 

210 

211def make_standard_renderer( 

212 *, 

213 example_processor: Processor | None = None, 

214 table_width: int | None = None, 

215) -> LegendRenderer: 

216 columns = [ 

217 ColumnConfig("element", header="Element", justify="center", ratio=1), 

218 ColumnConfig("description", header="Description", justify="center", ratio=2), 

219 ColumnConfig( 

220 "example", 

221 header="Diff Example", 

222 ratio=2.5, 

223 processor=example_processor, 

224 wrap=False, 

225 ), 

226 ] 

227 return LegendRenderer(columns, table_width=table_width)