Source code for gitlab_overviewer.rendering.table

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