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
« 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).
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"""
17from __future__ import annotations
19from dataclasses import dataclass
20from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, Mapping, Sequence, Union, cast
22if TYPE_CHECKING:
23 from .core.parameter_base import Compare
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
31StrOrRenderable = Union[str, RenderableType]
32Processor = Callable[..., StrOrRenderable | list[StrOrRenderable]]
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__")
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
50 def header_text(self) -> str:
51 return self.header or self.name
54@dataclass(slots=True)
55class Cell:
56 """Display‑ready cell."""
58 value: RenderableType
59 pad: bool = True # if False – value already framed (Panel/Table)
61 def renderable(self) -> RenderableType:
62 if self.pad:
63 return Padding(self.value, (0, 1))
64 return self.value
67class LegendRenderer:
68 """Facade to build a table from legend classes."""
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
87 # ------------------------------------------------------------------
88 # Public API
89 # ------------------------------------------------------------------
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)
95 rows: list[list[Cell]] = [self._build_row(legend) for legend in legends]
96 return self._build_table(rows)
98 def render(self, legend_classes: Iterable[type["Compare"]]) -> str:
99 table = self.rich_render(legend_classes)
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 )
109 with console.capture() as cap:
110 console.print(table, end="") # prevent extra newline
111 return cap.get()
113 # ------------------------------------------------------------------
114 # Internals
115 # ------------------------------------------------------------------
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)}")
124 # ---------- building ------------------------------------------------
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
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)
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)
159 # Primitive str / None
160 if data is None or isinstance(data, str):
161 return Cell("" if data is None else data, pad=pad)
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
179 # Fallback – safe repr (no ANSI leak)
180 return Cell(repr(data), pad=pad)
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 )
201 for r in rows:
202 tbl.add_row(*[c.renderable() for c in r])
203 return tbl
206# ---------------------------------------------------------------------------
207# Convenience factory
208# ---------------------------------------------------------------------------
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)