Skip to content

Commit eb2d671

Browse files
committed
feat(cli): add history trends with ASCII charts and optional matplotlib export
- introduce ASCII line chart + trend summary renderer - add matplotlib-based image export behind `viz` extra - wire new `pacta history trends` subcommand with filters and JSON output - add tests for trends output, ASCII rendering, and image export
1 parent a9effdf commit eb2d671

6 files changed

Lines changed: 841 additions & 0 deletions

File tree

pacta/cli/_ascii_chart.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""
2+
ASCII chart rendering for terminal output.
3+
4+
Provides simple chart rendering without external dependencies.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
10+
def render_line_chart(
11+
values: list[float],
12+
labels: list[str],
13+
*,
14+
width: int = 60,
15+
height: int = 10,
16+
title: str | None = None,
17+
) -> str:
18+
"""
19+
Render a simple ASCII line chart.
20+
21+
Args:
22+
values: Y-axis values (one per data point)
23+
labels: X-axis labels (one per data point)
24+
width: Chart width in characters
25+
height: Chart height in lines
26+
title: Optional chart title
27+
28+
Returns:
29+
ASCII chart as a string
30+
"""
31+
if not values:
32+
return "No data to display."
33+
34+
lines: list[str] = []
35+
36+
# Title
37+
if title:
38+
lines.append(title)
39+
lines.append("=" * len(title))
40+
lines.append("")
41+
42+
# Calculate scale
43+
min_val = min(values)
44+
max_val = max(values)
45+
46+
# Handle edge case where all values are the same
47+
if min_val == max_val:
48+
min_val = 0 if max_val > 0 else max_val - 1
49+
if max_val == 0:
50+
max_val = 1
51+
52+
val_range = max_val - min_val
53+
54+
# Y-axis label width (for padding)
55+
y_label_width = max(len(f"{max_val:.0f}"), len(f"{min_val:.0f}")) + 1
56+
57+
# Calculate chart area dimensions
58+
chart_width = min(width - y_label_width - 3, len(values) * 3)
59+
chart_width = max(chart_width, len(values)) # At least 1 char per point
60+
61+
# Map values to chart positions (column indices)
62+
if len(values) == 1:
63+
x_positions = [chart_width // 2]
64+
else:
65+
x_positions = [int((i / (len(values) - 1)) * (chart_width - 1)) for i in range(len(values))]
66+
67+
# Map values to row indices (0 = bottom, height-1 = top)
68+
def value_to_row(v: float) -> int:
69+
if val_range == 0:
70+
return height // 2
71+
normalized = (v - min_val) / val_range
72+
return int(normalized * (height - 1))
73+
74+
# Build the chart grid
75+
grid: list[list[str]] = [[" " for _ in range(chart_width)] for _ in range(height)]
76+
77+
# Plot points
78+
for i, v in enumerate(values):
79+
row = value_to_row(v)
80+
col = x_positions[i]
81+
if 0 <= row < height and 0 <= col < chart_width:
82+
grid[row][col] = "\u25cf" # Filled circle
83+
84+
# Connect points with lines (optional - use dashes for now)
85+
for i in range(len(values) - 1):
86+
row1 = value_to_row(values[i])
87+
row2 = value_to_row(values[i + 1])
88+
col1 = x_positions[i]
89+
col2 = x_positions[i + 1]
90+
91+
# Simple horizontal connection if on same row
92+
if row1 == row2 and col2 - col1 > 1:
93+
for c in range(col1 + 1, col2):
94+
if grid[row1][c] == " ":
95+
grid[row1][c] = "-"
96+
97+
# Render with Y-axis labels
98+
for row_idx in range(height - 1, -1, -1):
99+
# Show label at top, middle, and bottom
100+
if row_idx == height - 1:
101+
label = f"{max_val:>{y_label_width}.0f}"
102+
elif row_idx == 0:
103+
label = f"{min_val:>{y_label_width}.0f}"
104+
elif row_idx == height // 2:
105+
mid_val = (max_val + min_val) / 2
106+
label = f"{mid_val:>{y_label_width}.0f}"
107+
else:
108+
label = " " * y_label_width
109+
110+
# Y-axis line character
111+
if row_idx == 0:
112+
axis_char = "\u2514" # Bottom-left corner
113+
else:
114+
axis_char = "\u2502" # Vertical line
115+
116+
lines.append(f"{label} {axis_char}{''.join(grid[row_idx])}")
117+
118+
# X-axis
119+
lines.append(" " * (y_label_width + 1) + "\u2514" + "\u2500" * chart_width)
120+
121+
# X-axis labels (simplified - show first and last)
122+
if labels:
123+
label_line = " " * (y_label_width + 2)
124+
if len(labels) == 1:
125+
label_line += labels[0].center(chart_width)
126+
else:
127+
first_label = labels[0][:10]
128+
last_label = labels[-1][:10]
129+
spacing = chart_width - len(first_label) - len(last_label)
130+
if spacing > 0:
131+
label_line += first_label + " " * spacing + last_label
132+
else:
133+
label_line += first_label
134+
lines.append(label_line)
135+
136+
return "\n".join(lines)
137+
138+
139+
def render_trend_summary(
140+
values: list[float],
141+
labels: list[str],
142+
metric_name: str,
143+
) -> str:
144+
"""
145+
Render a summary of the trend.
146+
147+
Args:
148+
values: The data values
149+
labels: The date labels
150+
metric_name: Name of the metric being tracked
151+
152+
Returns:
153+
Summary text
154+
"""
155+
if not values:
156+
return "No data available."
157+
158+
lines: list[str] = []
159+
lines.append("")
160+
161+
first_val = values[0]
162+
last_val = values[-1]
163+
diff = last_val - first_val
164+
165+
# Trend direction
166+
if diff < 0:
167+
trend_icon = "\u2193" # Down arrow
168+
trend_word = "Improving" if metric_name == "violations" else "Decreasing"
169+
elif diff > 0:
170+
trend_icon = "\u2191" # Up arrow
171+
trend_word = "Worsening" if metric_name == "violations" else "Increasing"
172+
else:
173+
trend_icon = "\u2192" # Right arrow
174+
trend_word = "Stable"
175+
176+
diff_str = f"{diff:+.2f}" if isinstance(diff, float) and not diff.is_integer() else f"{diff:+.0f}"
177+
lines.append(f"Trend: {trend_icon} {trend_word} ({diff_str} over period)")
178+
179+
# First and last values
180+
first_str = (
181+
f"{first_val:.2f}" if isinstance(first_val, float) and not first_val.is_integer() else f"{first_val:.0f}"
182+
)
183+
last_str = f"{last_val:.2f}" if isinstance(last_val, float) and not last_val.is_integer() else f"{last_val:.0f}"
184+
185+
first_label = labels[0] if labels else "start"
186+
last_label = labels[-1] if labels else "end"
187+
188+
unit = _metric_unit(metric_name)
189+
lines.append(f"First: {first_str} {unit} ({first_label})")
190+
lines.append(f"Last: {last_str} {unit} ({last_label})")
191+
192+
# Statistics
193+
avg_val = float(sum(values) / len(values))
194+
max_val = max(values)
195+
min_val = min(values)
196+
197+
lines.append("")
198+
avg_str = f"{avg_val:.2f}" if not avg_val.is_integer() else f"{avg_val:.0f}"
199+
lines.append(f"Average: {avg_str} {unit}")
200+
lines.append(f"Min: {min_val:.0f}, Max: {max_val:.0f}")
201+
202+
return "\n".join(lines)
203+
204+
205+
def _metric_unit(metric_name: str) -> str:
206+
"""Get the display unit for a metric."""
207+
units = {
208+
"violations": "violations",
209+
"nodes": "nodes",
210+
"edges": "edges",
211+
"density": "ratio",
212+
}
213+
return units.get(metric_name, "")

pacta/cli/_mpl_chart.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""
2+
Matplotlib chart rendering for image export.
3+
4+
This module requires matplotlib (install with `pip install pacta[viz]`).
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from datetime import datetime
10+
from pathlib import Path
11+
12+
13+
def render_trends_chart(
14+
values: list[float],
15+
labels: list[str],
16+
*,
17+
metric: str,
18+
output_path: str,
19+
title: str | None = None,
20+
) -> None:
21+
"""
22+
Render a trends chart to an image file using matplotlib.
23+
24+
Args:
25+
values: Y-axis values (one per data point)
26+
labels: X-axis labels (one per data point)
27+
metric: The metric name being tracked
28+
output_path: Path to save the image (PNG, SVG, PDF supported)
29+
title: Optional chart title
30+
31+
Raises:
32+
ImportError: If matplotlib is not installed
33+
"""
34+
try:
35+
import matplotlib.dates as mdates
36+
import matplotlib.pyplot as plt
37+
except ImportError as e:
38+
raise ImportError("matplotlib is required for image export. Install it with: pip install pacta[viz]") from e
39+
40+
# Parse dates from labels if possible
41+
dates = []
42+
for label in labels:
43+
try:
44+
# Try to parse "Mon DD" format
45+
dt = datetime.strptime(label, "%b %d")
46+
# Use current year
47+
dt = dt.replace(year=datetime.now().year)
48+
dates.append(dt)
49+
except ValueError:
50+
dates = None
51+
break
52+
53+
# Create figure
54+
fig, ax = plt.subplots(figsize=(10, 6))
55+
56+
# Plot data
57+
if dates:
58+
ax.plot(dates, values, marker="o", linewidth=2, markersize=8, color="#2563eb")
59+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d"))
60+
ax.xaxis.set_major_locator(mdates.AutoDateLocator())
61+
plt.xticks(rotation=45)
62+
else:
63+
x_positions = list(range(len(values)))
64+
ax.plot(x_positions, values, marker="o", linewidth=2, markersize=8, color="#2563eb")
65+
ax.set_xticks(x_positions)
66+
ax.set_xticklabels(labels, rotation=45, ha="right")
67+
68+
# Styling
69+
metric_labels = {
70+
"violations": "Violations",
71+
"nodes": "Node Count",
72+
"edges": "Edge Count",
73+
"density": "Density (edges/nodes)",
74+
}
75+
y_label = metric_labels.get(metric, metric.title())
76+
ax.set_ylabel(y_label, fontsize=12)
77+
ax.set_xlabel("Date", fontsize=12)
78+
79+
# Title
80+
if title:
81+
ax.set_title(title, fontsize=14, fontweight="bold")
82+
else:
83+
ax.set_title(f"{y_label} Over Time", fontsize=14, fontweight="bold")
84+
85+
# Grid
86+
ax.grid(True, alpha=0.3)
87+
ax.set_axisbelow(True)
88+
89+
# Fill area under curve
90+
if dates:
91+
ax.fill_between(dates, values, alpha=0.1, color="#2563eb")
92+
else:
93+
ax.fill_between(x_positions, values, alpha=0.1, color="#2563eb")
94+
95+
# Add trend annotation
96+
if len(values) >= 2:
97+
diff = values[-1] - values[0]
98+
if diff < 0:
99+
trend_text = f"Trend: {diff:+.1f} (Improving)" if metric == "violations" else f"Trend: {diff:+.1f}"
100+
trend_color = "#16a34a" # Green
101+
elif diff > 0:
102+
trend_text = f"Trend: {diff:+.1f} (Worsening)" if metric == "violations" else f"Trend: {diff:+.1f}"
103+
trend_color = "#dc2626" # Red
104+
else:
105+
trend_text = "Trend: Stable"
106+
trend_color = "#6b7280" # Gray
107+
108+
ax.annotate(
109+
trend_text,
110+
xy=(0.02, 0.98),
111+
xycoords="axes fraction",
112+
fontsize=10,
113+
color=trend_color,
114+
verticalalignment="top",
115+
fontweight="bold",
116+
)
117+
118+
# Tight layout
119+
plt.tight_layout()
120+
121+
# Save
122+
output = Path(output_path)
123+
fig.savefig(output, dpi=150, bbox_inches="tight")
124+
plt.close(fig)
125+
126+
127+
def is_matplotlib_available() -> bool:
128+
"""Check if matplotlib is installed."""
129+
try:
130+
import matplotlib # noqa: F401
131+
132+
return True
133+
except ImportError:
134+
return False

0 commit comments

Comments
 (0)