1"""
2Table rendering utilities for repository overview.
3
4Implements :any:`/specs/spec_table_rendering_ui` §1-6, covering:
5
6* Input contract (§1)
7* Column handling (§2)
8* Table structure (§3)
9* Star scale (§4)
10* Group sections (§5)
11* Error handling (§6)
12"""
13
14from typing import Callable, Optional, Dict, Union, List
15from .renderer_base import Renderer
16
17
[docs]
18class TableConfigurationError(Exception):
19 """Exception raised for table configuration errors."""
20
21 pass
22
23
24def validate_column_configuration(
25 table_config: dict, overview_table: Union[dict[str, dict], list[tuple[str, dict]]]
26) -> None:
27 """Validate table column configuration against the data.
28
29 Args:
30 table_config: Configuration dict with 'columns' list
31 overview_table: The table data to validate against
32
33 Raises:
34 TableConfigurationError: If any validation fails (except missing keys in rows)
35 """
36 if "columns" not in table_config:
37 raise TableConfigurationError("Missing 'columns' in table configuration")
38
39 # Collect *all* columns present across rows. A column key is considered
40 # "known" if it appears in **any** row – this aligns with the rendering
41 # spec that renders a dash (—) for missing values (§2-3 in
42 # spec_table_rendering_ui.md). Relying on the first row alone would raise
43 # false-positive configuration errors when that row happens to omit a
44 # valid but optional column.
45 if isinstance(overview_table, dict):
46 rows_iter = overview_table.values()
47 else:
48 rows_iter = (row for _key, row in overview_table)
49
50 available_columns: set[str] = set()
51 for _row in rows_iter:
52 if _row:
53 available_columns.update(_row.keys())
54
55 if not available_columns:
56 # Empty table – nothing to validate
57 return
58
59 seen_keys = set()
60
61 for col in table_config["columns"]:
62 # Check required fields
63 if "key" not in col:
64 raise TableConfigurationError("Missing 'key' in column configuration")
65
66 key = col["key"]
67
68 # Check for duplicate keys
69 if key in seen_keys:
70 raise TableConfigurationError(f"Duplicate column key '{key}'")
71 seen_keys.add(key)
72
73 # Check for unknown column keys – spec requires configuration error
74 if key not in available_columns:
75 raise TableConfigurationError(f"Unknown column key '{key}'")
76
77 # Treat missing 'visible' as spec default (True)
78 if "visible" not in col:
79 # Inject default to avoid KeyErrors downstream
80 col["visible"] = True
81
82
83def render_table_row(cells: List[str]) -> str:
84 """Render a table row without trailing whitespace.
85
86 Args:
87 cells: List of cell values to render
88
89 Returns:
90 Rendered table row without trailing whitespace
91 """
92 # Join cells without trailing spaces
93 return "|" + "|".join(f" {cell.rstrip()} " for cell in cells) + "|"
94
95
96def render_row_for_table(
97 row: dict,
98 table_keys: list,
99 rowmod: Optional[Callable[[Optional[str], str, dict], dict]] = None,
100 group: Optional[str] = None,
101 rname: str = "",
102 max_priority: int = 5,
103 max_urgency: int = 5,
104) -> str:
105 """Copy, apply ``rowmod``, format priority/urgency with star-ratings, and
106 return a rendered row string for the table.
107
108 Args:
109 row: The data row.
110 table_keys: Column keys to render in order.
111 rowmod: Optional transformation callback applied before formatting.
112 group: Current group name (if any) for ``rowmod``.
113 rname: Row key / repository name.
114 max_priority: Dynamic maximum star count for ``priority``.
115 max_urgency: Dynamic maximum star count for ``urgency``.
116 """
117
118 if rowmod:
119 row = rowmod(group, rname, row)
120
121 row = Renderer.format_priority_urgency(
122 row, "priority", "urgency", max_priority, max_urgency
123 )
124
125 # Render all cells for a row, applying em-dash placeholder handling.
126 cells = [Renderer.safe_str(row.get(k, None)) for k in table_keys]
127 return render_table_row(cells)
128
129
[docs]
130def render_table(
131 overview_table: Union[dict[str, dict], list[tuple[str, dict]]],
132 table_config: dict,
133 rowmod: Optional[Callable[[Optional[str], str, dict], dict]] = None,
134 max_priority: int = 5,
135 max_urgency: int = 5,
136 group_by: Optional[Dict[str, list]] = None,
137) -> str:
138 """Render a Markdown table for the repository overview.
139
140 Args:
141 overview_table: Dict with repo names as keys and column values as values,
142 or list of (key, dict) tuples (for sorted tables)
143 table_config: Dict with 'columns' (keys, labels, visible) and optional 'group_notes'
144 rowmod: Optional function to modify each row
145 max_priority: For star rating display
146 max_urgency: For star rating display
147 group_by: Optional grouping dict (e.g. REPOS_BY_GROUP)
148
149 Returns:
150 Rendered Markdown table as string
151
152 Raises:
153 TableConfigurationError: If column configuration is invalid
154 """
155 # Validate column configuration
156 validate_column_configuration(table_config, overview_table)
157
158 # Columns with 'visible' omitted are considered visible by default (spec)
159 visible_columns = [
160 col for col in table_config["columns"] if col.get("visible", True)
161 ]
162 table_keys = [col["key"] for col in visible_columns]
163 table_labels = [col.get("label", col["key"]) for col in visible_columns]
164
165 # Get configurable group notes
166 group_notes_config = table_config.get("group_notes", {})
167 default_note = group_notes_config.get("default", "")
168 group_specific_notes = group_notes_config.get("groups", {})
169
170 # Initialize output without trailing whitespace
171 out = []
172
173 # ---------------------------------------------------------------
174 # Compute dynamic star-rating scale (min(5, max(value)))
175 # ---------------------------------------------------------------
176 max_priority_val = 5
177 max_urgency_val = 5
178
179 def _update_max(row_dict: dict):
180 nonlocal max_priority_val, max_urgency_val
181 if row_dict is None:
182 return
183 if "priority" in row_dict:
184 max_priority_val = max(
185 max_priority_val, Renderer.safe_int(row_dict.get("priority", 0))
186 )
187 if "urgency" in row_dict:
188 max_urgency_val = max(
189 max_urgency_val, Renderer.safe_int(row_dict.get("urgency", 0))
190 )
191
192 # Iterate through overview_table to determine maxima
193 if isinstance(overview_table, dict):
194 for _name, _row in overview_table.items():
195 _row_copy = _row.copy()
196 if rowmod:
197 # We don't yet know group context, pass None
198 _row_copy = rowmod(None, _name, _row_copy)
199 _update_max(_row_copy)
200 else:
201 for _name, _row in overview_table:
202 _row_copy = _row.copy()
203 if rowmod:
204 _row_copy = rowmod(None, _name, _row_copy)
205 _update_max(_row_copy)
206
207 if group_by is None:
208 # Flat table
209 out.append(render_table_row(table_labels))
210 out.append(render_table_row(["---"] * len(table_labels)))
211
212 if isinstance(overview_table, dict):
213 items = overview_table.items()
214 else:
215 items = overview_table
216
217 for rname, row in items:
218 out.append(
219 render_row_for_table(
220 row.copy(),
221 table_keys,
222 rowmod,
223 None,
224 rname or "",
225 max_priority_val,
226 max_urgency_val,
227 )
228 )
229 else:
230 # Grouped table
231 if isinstance(overview_table, dict):
232 items = list(overview_table.items())
233 else:
234 items = list(overview_table)
235
236 # Preserve the insertion order of *group_by* so that callers can
237 # explicitly control the section ordering (tests rely on this
238 # behaviour). If callers prefer automatic ordering, they can pass in
239 # an *OrderedDict* or pre-sort the mapping themselves before handing
240 # it over to render_table.
241 for gname, rnames in group_by.items():
242 out.append(f"### {gname}")
243 out.append("") # Empty line after heading
244 out.append(render_table_row(table_labels))
245 out.append(render_table_row(["---"] * len(table_labels)))
246
247 # Group members in sorted order
248 group_items = [item for item in items if item[0] in rnames]
249 for rname, row in group_items:
250 out.append(
251 render_row_for_table(
252 row.copy(),
253 table_keys,
254 rowmod,
255 gname,
256 rname or "",
257 max_priority_val,
258 max_urgency_val,
259 )
260 )
261
262 out.append("") # Empty line after table
263
264 # Use configurable note for this group
265 # Prefer exact key match first; fall back to case-insensitive match
266 note = group_specific_notes.get(gname)
267 if note is None:
268 note = group_specific_notes.get(gname.lower(), default_note)
269 if note is None:
270 note = default_note
271 if note:
272 out.append(note)
273 out.append("") # Empty line after note
274
275 # ------------------------------------------------------------------
276 # Append Ungrouped section (projects not covered by *group_by*)
277 # ------------------------------------------------------------------
278
279 if group_by is not None:
280 grouped_repo_names = {rname for names in group_by.values() for rname in names}
281 # Determine items depending on overview_table structure
282 if isinstance(overview_table, dict):
283 all_items = list(overview_table.items())
284 else:
285 all_items = list(overview_table)
286
287 ungrouped_items = [
288 item for item in all_items if item[0] not in grouped_repo_names
289 ]
290
291 if ungrouped_items:
292 out.append("### Ungrouped")
293 out.append("")
294 out.append(render_table_row(table_labels))
295 out.append(render_table_row(["---"] * len(table_labels)))
296
297 for rname, row in ungrouped_items:
298 out.append(
299 render_row_for_table(
300 row.copy(),
301 table_keys,
302 rowmod,
303 None,
304 rname or "",
305 max_priority_val,
306 max_urgency_val,
307 )
308 )
309
310 out.append("")
311
312 # Use configurable note for ungrouped section
313 if default_note:
314 out.append(default_note)
315 out.append("")
316
317 # Join lines without trailing whitespace
318 return "\n".join(line.rstrip() for line in out)
319
320
321__all__ = ["render_table", "TableConfigurationError"]