Skip to content

Commit ff9ff79

Browse files
authored
Merge pull request #29 from baselinrhq/feat/rich-cli
feat: added rich UI to rest of cli commands
2 parents cf3fb7d + 167f1d8 commit ff9ff79

File tree

7 files changed

+936
-60
lines changed

7 files changed

+936
-60
lines changed

baselinr/cli.py

Lines changed: 338 additions & 52 deletions
Large diffs are not rendered by default.

baselinr/cli_output.py

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
"""
2+
Rich output utilities for Baselinr CLI.
3+
4+
Provides consistent formatting, colors, and status indicators across all CLI commands.
5+
Uses modern soft color palette matching the dashboard style.
6+
"""
7+
8+
import json
9+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
10+
11+
try:
12+
from rich.console import Console
13+
from rich.panel import Panel
14+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
15+
from rich.text import Text
16+
17+
RICH_AVAILABLE = True
18+
except ImportError:
19+
RICH_AVAILABLE = False
20+
if TYPE_CHECKING:
21+
from rich.text import Text # type: ignore
22+
23+
# Modern soft color palette matching dashboard style
24+
COLORS = {
25+
"success": "#52b788", # Soft mint/teal
26+
"warning": "#f4a261", # Soft amber/gold
27+
"error": "#ff8787", # Soft coral/rose
28+
"info": "#4a90e2", # Soft blue/cyan
29+
"profiling": "#4a90e2", # Soft blue/cyan
30+
"optimized": "#a78bfa", # Soft magenta/purple
31+
"drift_check": "#f4a261", # Soft amber/gold
32+
"anomaly": "#ff8787", # Soft coral/rose
33+
}
34+
35+
# Default console instance
36+
_console: Optional["Console"] = None
37+
38+
39+
def get_console() -> "Console":
40+
"""Get or create Rich Console instance with error handling."""
41+
global _console
42+
if _console is None:
43+
try:
44+
_console = Console()
45+
except (UnicodeEncodeError, OSError, ImportError):
46+
# Fallback to plain console if Rich fails
47+
_console = None
48+
return _console
49+
50+
51+
def get_status_indicator(state: str) -> "Text":
52+
"""
53+
Get colored status indicator dot matching dashboard style.
54+
55+
Args:
56+
state: Status state ("profiling", "drift_check", "warning", "anomaly",
57+
"optimized", "success")
58+
59+
Returns:
60+
Rich Text with colored dot indicator
61+
"""
62+
if not RICH_AVAILABLE:
63+
# Fallback to plain text
64+
return Text("●")
65+
66+
color_map = {
67+
"profiling": COLORS["profiling"],
68+
"drift_check": COLORS["drift_check"],
69+
"warning": COLORS["warning"],
70+
"anomaly": COLORS["anomaly"],
71+
"optimized": COLORS["optimized"],
72+
"success": COLORS["success"],
73+
"healthy": COLORS["success"],
74+
}
75+
76+
color = color_map.get(state, COLORS["info"])
77+
return Text("●", style=f"bold {color}")
78+
79+
80+
def get_severity_color(severity: str) -> str:
81+
"""
82+
Get color for drift severity.
83+
84+
Args:
85+
severity: Severity level ("high", "medium", "low", "none")
86+
87+
Returns:
88+
Color hex code
89+
"""
90+
severity_map = {
91+
"high": COLORS["error"],
92+
"medium": COLORS["warning"],
93+
"low": COLORS["warning"],
94+
"none": COLORS["success"],
95+
}
96+
return severity_map.get(severity.lower(), COLORS["info"])
97+
98+
99+
def format_run_summary(
100+
duration_seconds: float,
101+
tables_scanned: int,
102+
drifts_detected: int = 0,
103+
warnings: int = 0,
104+
anomalies: int = 0,
105+
) -> Any:
106+
"""
107+
Format post-run summary with Rich Panel.
108+
109+
Args:
110+
duration_seconds: Total duration in seconds
111+
tables_scanned: Number of tables scanned
112+
drifts_detected: Number of drifts detected
113+
warnings: Number of warnings
114+
anomalies: Number of anomalies
115+
116+
Returns:
117+
Formatted summary string
118+
"""
119+
if not RICH_AVAILABLE:
120+
# Fallback to plain text
121+
duration_str = (
122+
f"{duration_seconds:.1f}s" if duration_seconds < 60 else f"{duration_seconds / 60:.1f}m"
123+
)
124+
parts = [f"{tables_scanned} tables scanned"]
125+
if drifts_detected > 0:
126+
parts.append(f"{drifts_detected} drifts detected")
127+
if warnings > 0:
128+
parts.append(f"{warnings} warnings")
129+
if anomalies > 0:
130+
parts.append(f"{anomalies} anomalies")
131+
return f"Profiling completed in {duration_str}\n\n" + " • ".join(parts)
132+
133+
console = get_console()
134+
if not console:
135+
return format_run_summary(
136+
duration_seconds, tables_scanned, drifts_detected, warnings, anomalies
137+
)
138+
139+
# Format duration
140+
if duration_seconds < 60:
141+
duration_str = f"{duration_seconds:.1f}s"
142+
elif duration_seconds < 3600:
143+
duration_str = f"{duration_seconds / 60:.1f}m"
144+
else:
145+
duration_str = f"{duration_seconds / 3600:.1f}h"
146+
147+
# Build summary text with colored indicators
148+
summary_parts = []
149+
success_color = COLORS["success"]
150+
summary_parts.append(
151+
f"[bold {success_color}]Profiling completed in {duration_str}" f"[/bold {success_color}]"
152+
)
153+
summary_parts.append("")
154+
155+
stats = []
156+
stats.append(f"[cyan]{tables_scanned}[/cyan] tables scanned")
157+
if drifts_detected > 0:
158+
stats.append(
159+
f"[{COLORS['warning']}]{drifts_detected} drifts detected[/{COLORS['warning']}]"
160+
)
161+
if warnings > 0:
162+
stats.append(f"[{COLORS['warning']}]{warnings} warnings[/{COLORS['warning']}]")
163+
if anomalies > 0:
164+
stats.append(f"[{COLORS['error']}]{anomalies} anomalies[/{COLORS['error']}]")
165+
166+
summary_parts.append(" • ".join(stats))
167+
168+
summary_text = "\n".join(summary_parts)
169+
170+
# Create panel with soft border
171+
panel = Panel.fit(
172+
summary_text,
173+
border_style=COLORS["info"],
174+
title="[bold]Summary[/bold]",
175+
)
176+
177+
# Return the panel directly instead of capturing - let safe_print handle it
178+
# This avoids ANSI code issues
179+
return panel
180+
181+
182+
def render_histogram(
183+
baseline: List[Dict[str, Any]], current: List[Dict[str, Any]], bins: int = 10
184+
) -> str:
185+
"""
186+
Render inline histogram comparison for distribution changes.
187+
188+
Args:
189+
baseline: Baseline histogram data (list of {bin, count} dicts)
190+
current: Current histogram data (list of {bin, count} dicts)
191+
bins: Number of bins
192+
193+
Returns:
194+
Formatted histogram string
195+
"""
196+
if not RICH_AVAILABLE or not baseline or not current:
197+
return "[Histogram data not available]"
198+
199+
console = get_console()
200+
if not console:
201+
return "[Histogram data not available]"
202+
203+
# Normalize histogram data
204+
def normalize_hist(hist_data: List[Dict[str, Any]]) -> List[float]:
205+
"""Normalize histogram to 0-1 range for display."""
206+
if not hist_data:
207+
return []
208+
counts = [item.get("count", 0) for item in hist_data if isinstance(item, dict)]
209+
if not counts:
210+
return []
211+
max_count = max(counts) if counts else 1
212+
return [c / max_count if max_count > 0 else 0 for c in counts]
213+
214+
baseline_norm = normalize_hist(baseline)
215+
current_norm = normalize_hist(current)
216+
217+
# Create simple bar chart representation
218+
max_bars = 20 # Maximum width for histogram bars
219+
lines = []
220+
lines.append("[bold]Distribution Comparison[/bold]")
221+
lines.append("")
222+
223+
# Find max length for alignment
224+
max_len = max(len(baseline_norm), len(current_norm))
225+
for i in range(max_len):
226+
baseline_val = baseline_norm[i] if i < len(baseline_norm) else 0
227+
current_val = current_norm[i] if i < len(current_norm) else 0
228+
229+
baseline_bars = int(baseline_val * max_bars)
230+
current_bars = int(current_val * max_bars)
231+
232+
baseline_bar = "█" * baseline_bars
233+
current_bar = "█" * current_bars
234+
235+
info_color = COLORS["info"]
236+
warning_color = COLORS["warning"]
237+
lines.append(
238+
f"Bin {i+1:2d}: [dim]Baseline:[/dim] [{info_color}]{baseline_bar}"
239+
f"[/{info_color}] [dim]Current:[/dim] [{warning_color}]{current_bar}"
240+
f"[/{warning_color}]"
241+
)
242+
243+
return "\n".join(lines)
244+
245+
246+
def create_progress_bar(total: int, description: str = "Processing") -> Optional["Progress"]:
247+
"""
248+
Create Rich progress bar for long operations.
249+
250+
Args:
251+
total: Total number of items to process
252+
description: Description text for progress bar
253+
254+
Returns:
255+
Rich Progress instance or None if Rich unavailable
256+
"""
257+
if not RICH_AVAILABLE:
258+
return None
259+
260+
console = get_console()
261+
if not console:
262+
return None
263+
264+
progress = Progress(
265+
SpinnerColumn(),
266+
TextColumn("[progress.description]{task.description}"),
267+
BarColumn(),
268+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
269+
console=console,
270+
)
271+
272+
return progress
273+
274+
275+
def format_drift_severity(severity: str) -> "Text":
276+
"""
277+
Format drift severity with appropriate color.
278+
279+
Args:
280+
severity: Severity level ("high", "medium", "low", "none")
281+
282+
Returns:
283+
Rich Text with colored severity
284+
"""
285+
if not RICH_AVAILABLE:
286+
return Text(severity.upper())
287+
288+
color = get_severity_color(severity)
289+
return Text(severity.upper(), style=f"bold {color}")
290+
291+
292+
def extract_histogram_data(metric_data: Any) -> Optional[List[Dict[str, Any]]]:
293+
"""
294+
Extract histogram data from metric results.
295+
296+
Args:
297+
metric_data: Metric data (could be dict, string JSON, or None)
298+
299+
Returns:
300+
List of histogram bins with {bin, count} or None if not available
301+
"""
302+
if not metric_data:
303+
return None
304+
305+
try:
306+
# If it's a string, try to parse as JSON
307+
if isinstance(metric_data, str):
308+
parsed = json.loads(metric_data)
309+
if isinstance(parsed, list):
310+
return parsed
311+
elif isinstance(parsed, dict) and "histogram" in parsed:
312+
return extract_histogram_data(parsed["histogram"])
313+
314+
# If it's a dict, look for histogram key
315+
if isinstance(metric_data, dict):
316+
if "histogram" in metric_data:
317+
return extract_histogram_data(metric_data["histogram"])
318+
# If the dict itself looks like histogram data
319+
if "bin" in metric_data or "count" in metric_data:
320+
return [metric_data]
321+
322+
# If it's a list, assume it's histogram data
323+
if isinstance(metric_data, list):
324+
return metric_data
325+
326+
except (json.JSONDecodeError, TypeError, AttributeError):
327+
pass
328+
329+
return None
330+
331+
332+
def safe_print(*args, **kwargs) -> None:
333+
"""
334+
Safely print using Rich Console with fallback to plain print.
335+
336+
Handles UnicodeEncodeError and other terminal issues gracefully.
337+
"""
338+
console = get_console()
339+
if console and RICH_AVAILABLE:
340+
try:
341+
console.print(*args, **kwargs)
342+
except (UnicodeEncodeError, OSError):
343+
# Fallback to plain print
344+
print(*args, **kwargs)
345+
else:
346+
print(*args, **kwargs)

0 commit comments

Comments
 (0)