669 lines
24 KiB
Python
669 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
# AUTHOR: Daesorin
|
|
# CREATED: 2026-07-12 09:40 +0100
|
|
# UPDATED: 2026-07-27 10:53 +0100
|
|
|
|
"""
|
|
hledger-charts
|
|
|
|
terminal and image charts for hledger csv output. reads csv from standard
|
|
input, piped directly from hledger, and renders an ascii chart to the
|
|
terminal, a matplotlib png, or both.
|
|
|
|
ARCH DIFFERENCE
|
|
matplotlib is only required for --png output, ascii charts use the standard
|
|
library alone. install matplotlib via pacman rather than pip where possible:
|
|
|
|
pacman -S python-matplotlib
|
|
|
|
pip installs into an externally managed environment fail under PEP 668
|
|
unless a virtualenv is used or --break-system-packages is passed.
|
|
|
|
INPUT
|
|
pipe hledger csv output in directly, or pass --journal so the script
|
|
calls hledger itself. pass the -O csv flag when piping directly from
|
|
hledger. the --format flag specifies a custom line format string and
|
|
produces incompatible text:
|
|
|
|
hledger balance -O csv | hledger-charts pie --png pie.png
|
|
hledger-charts pie --journal .journal/2026.journal --png pie.png
|
|
|
|
histogram and pie read a single period balance report. trend, stacked, and
|
|
grouped read a multi period balance report (hledger balance -M), which the
|
|
script requests automatically when --journal is given. pass an account pattern
|
|
or date range as trailing query arguments to focus the chart. all trailing
|
|
arguments and flags after the chart options are passed straight through to
|
|
hledger balance:
|
|
|
|
hledger-charts stacked --journal .journal/2026.journal expenses
|
|
hledger-charts trend --journal .journal/2026.journal expenses -b 2026-01 -e 2026-04
|
|
"""
|
|
|
|
import argparse
|
|
import csv
|
|
import io
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
SPARK_CHARS = "▁▂▃▄▅▆▇█"
|
|
ANSI_COLOURS = [31, 33, 32, 36, 34, 35, 91, 93, 92, 96, 94, 95]
|
|
|
|
|
|
# AMOUNT PARSING
|
|
# shared by both single period and multi period csv shapes
|
|
|
|
def clean_amount(raw, invert=False):
|
|
"""EXTRACT THE FIRST NUMERIC VALUE FROM CURRENCY AND FORMATTED STRINGS
|
|
accepts formats such as "1,200.00 NGN", "NGN -1,200.00", "- 450.00"
|
|
"""
|
|
cleaned = raw.replace(",", "")
|
|
cleaned = re.sub(r"([-+])\s+", r"\1", cleaned)
|
|
match = re.search(r"[-+]?\d+(?:\.\d+)?", cleaned)
|
|
if not match:
|
|
return 0.0
|
|
val = float(match.group(0))
|
|
return -val if invert else val
|
|
|
|
|
|
# ACCOUNT HIERARCHY
|
|
|
|
def rollup_account(account, depth):
|
|
"""TRUNCATE ACCOUNT NAME TO A SPECIFIED HIERARCHY DEPTH"""
|
|
if not depth or depth <= 0:
|
|
return account
|
|
parts = account.split(":")
|
|
return ":".join(parts[:depth])
|
|
|
|
|
|
def aggregate_single_period(accounts, amounts, depth):
|
|
"""GROUP SINGLE PERIOD AMOUNTS BY TRUNCATED ACCOUNT NAME"""
|
|
if not depth or depth <= 0:
|
|
return accounts, amounts
|
|
grouped = {}
|
|
for acc, amt in zip(accounts, amounts):
|
|
parent = rollup_account(acc, depth)
|
|
grouped[parent] = grouped.get(parent, 0.0) + amt
|
|
return list(grouped.keys()), list(grouped.values())
|
|
|
|
|
|
def aggregate_multi_period(accounts, periods, matrix, depth):
|
|
"""GROUP MULTI PERIOD MATRICES BY TRUNCATED ACCOUNT NAME"""
|
|
if not depth or depth <= 0:
|
|
return accounts, periods, matrix
|
|
grouped = {}
|
|
n_periods = len(periods)
|
|
for i, acc in enumerate(accounts):
|
|
parent = rollup_account(acc, depth)
|
|
if parent not in grouped:
|
|
grouped[parent] = [0.0] * n_periods
|
|
for j in range(n_periods):
|
|
grouped[parent][j] += matrix[i][j]
|
|
return list(grouped.keys()), periods, list(grouped.values())
|
|
|
|
|
|
# CSV PARSING
|
|
|
|
def parse_balance_csv(rows, invert=False):
|
|
"""PARSE A SINGLE PERIOD BALANCE REPORT
|
|
expects rows shaped like [account, amount], skips headers and totals
|
|
"""
|
|
accounts = []
|
|
amounts = []
|
|
for row in rows:
|
|
if len(row) < 2:
|
|
continue
|
|
account = row[0].strip()
|
|
if account.lower().rstrip(" :") in ("account", "total", ""):
|
|
continue
|
|
try:
|
|
amount = clean_amount(row[1], invert=invert)
|
|
except ValueError:
|
|
continue
|
|
accounts.append(account)
|
|
amounts.append(amount)
|
|
return accounts, amounts
|
|
|
|
|
|
def parse_period_csv(rows, invert=False):
|
|
"""PARSE A MULTI PERIOD BALANCE REPORT
|
|
expects a header of account,period1,... and drops any trailing total column
|
|
"""
|
|
if not rows:
|
|
return [], [], []
|
|
header = [c.strip() for c in rows[0]]
|
|
if len(header) < 2:
|
|
raise ValueError(
|
|
"period csv needs an account column and at least one value column"
|
|
)
|
|
periods = header[1:]
|
|
if len(periods) > 1 and periods[-1].lower().rstrip(" :") == "total":
|
|
periods = periods[:-1]
|
|
accounts = []
|
|
matrix = []
|
|
for row in rows[1:]:
|
|
if len(row) < 2:
|
|
continue
|
|
account = row[0].strip()
|
|
if account.lower().rstrip(" :") in ("account", "total", ""):
|
|
continue
|
|
values = row[1:1 + len(periods)]
|
|
try:
|
|
parsed = [clean_amount(v, invert=invert) for v in values]
|
|
except ValueError:
|
|
continue
|
|
accounts.append(account)
|
|
matrix.append(parsed)
|
|
return accounts, periods, matrix
|
|
|
|
|
|
# DATA LOADING
|
|
|
|
def load_csv_rows(args, period):
|
|
"""LOAD BALANCE REPORT ROWS WITH TTY AND PIPELINE VALIDATION
|
|
calls hledger directly when --journal is given, otherwise reads csv from stdin
|
|
"""
|
|
if not args.journal:
|
|
if sys.stdin.isatty():
|
|
print("error: no input piped to stdin and --journal not specified", file=sys.stderr)
|
|
sys.exit(1)
|
|
if args.query:
|
|
print("warning: query arguments are ignored when reading pre-computed csv from stdin", file=sys.stderr)
|
|
return list(csv.reader(sys.stdin))
|
|
cmd = ["hledger", "-f", args.journal, "balance", "-O", "csv"]
|
|
if period:
|
|
cmd.append("-M")
|
|
cmd.extend(args.query)
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
except FileNotFoundError:
|
|
print("error: hledger not found on PATH", file=sys.stderr)
|
|
sys.exit(1)
|
|
if result.returncode != 0:
|
|
print(f"error: hledger exited with status {result.returncode}", file=sys.stderr)
|
|
if result.stderr:
|
|
print(result.stderr.strip(), file=sys.stderr)
|
|
sys.exit(1)
|
|
return list(csv.reader(io.StringIO(result.stdout)))
|
|
|
|
|
|
# RANKING HELPERS
|
|
# keep charts readable when the account list is long
|
|
|
|
def top_n_with_other(labels, values, n):
|
|
"""COLLAPSE LOW RANKED ENTRIES INTO A SINGLE OTHER CATEGORY"""
|
|
paired = sorted(zip(labels, values), key=lambda p: abs(p[1]), reverse=True)
|
|
if n <= 0 or len(paired) <= n:
|
|
return [p[0] for p in paired], [p[1] for p in paired]
|
|
top = paired[:n]
|
|
rest = paired[n:]
|
|
other_total = sum(v for _, v in rest)
|
|
out_labels = [p[0] for p in top]
|
|
out_values = [p[1] for p in top]
|
|
if other_total != 0:
|
|
out_labels.append("other")
|
|
out_values.append(other_total)
|
|
return out_labels, out_values
|
|
|
|
|
|
def top_n_matrix_with_other(accounts, matrix, n):
|
|
"""COLLAPSE LOW RANKED ACCOUNTS INTO AN OTHER ROW ACROSS ALL PERIODS"""
|
|
totals = [sum(abs(v) for v in row) for row in matrix]
|
|
order = sorted(range(len(accounts)), key=lambda i: totals[i], reverse=True)
|
|
if n <= 0 or len(order) <= n:
|
|
idx = order
|
|
return [accounts[i] for i in idx], [matrix[i] for i in idx]
|
|
top_idx = order[:n]
|
|
rest_idx = order[n:]
|
|
top_accounts = [accounts[i] for i in top_idx]
|
|
top_matrix = [matrix[i] for i in top_idx]
|
|
n_periods = len(matrix[0]) if matrix else 0
|
|
other_row = [sum(matrix[i][j] for i in rest_idx) for j in range(n_periods)]
|
|
if any(other_row):
|
|
top_accounts.append("other")
|
|
top_matrix.append(other_row)
|
|
return top_accounts, top_matrix
|
|
|
|
|
|
# ASCII RENDERING PRIMITIVES
|
|
|
|
def use_colour(args):
|
|
if args.no_colour:
|
|
return False
|
|
if os.environ.get("NO_COLOR"):
|
|
return False
|
|
return sys.stdout.isatty()
|
|
|
|
|
|
def sparkline(values):
|
|
"""RENDER A COMPACT UNICODE SPARKLINE FOR ONE SERIES"""
|
|
if not values:
|
|
return ""
|
|
lo = min(values)
|
|
hi = max(values)
|
|
span = hi - lo
|
|
if span == 0:
|
|
return SPARK_CHARS[0] * len(values)
|
|
out = []
|
|
for v in values:
|
|
idx = int(((v - lo) / span) * (len(SPARK_CHARS) - 1))
|
|
out.append(SPARK_CHARS[idx])
|
|
return "".join(out)
|
|
|
|
|
|
def multiline_sparkline(values, rows=2):
|
|
"""RENDER A MULTI-LINE ASCII SPARKLINE CANVAS"""
|
|
if not values or rows <= 1:
|
|
return [sparkline(values)]
|
|
lo = min(values)
|
|
hi = max(values)
|
|
span = hi - lo if hi != lo else 1.0
|
|
canvas = [[" " for _ in values] for _ in range(rows)]
|
|
for col, v in enumerate(values):
|
|
norm = (v - lo) / span
|
|
scaled = norm * rows
|
|
full_blocks = int(scaled)
|
|
remainder = scaled - full_blocks
|
|
for r in range(full_blocks):
|
|
if r < rows:
|
|
canvas[rows - 1 - r][col] = "█"
|
|
if full_blocks < rows:
|
|
idx = int(remainder * len(SPARK_CHARS))
|
|
if idx > 0 or full_blocks == 0:
|
|
canvas[rows - 1 - full_blocks][col] = SPARK_CHARS[min(idx, len(SPARK_CHARS) - 1)]
|
|
return ["".join(row) for row in canvas]
|
|
|
|
|
|
def segment_glyph(i, colour):
|
|
"""PICK A GLYPH FOR SEGMENT I
|
|
coloured terminals reuse a plain block since colour carries the
|
|
distinction, plain terminals fall back to a letter per segment so
|
|
adjacent segments of the same rendered width stay distinguishable
|
|
"""
|
|
if colour:
|
|
return "█"
|
|
return chr(ord("A") + (i % 26))
|
|
|
|
|
|
def render_stacked_bar(labels, values, width, colour):
|
|
"""RENDER ONE PROPORTIONAL STACKED BAR ACROSS THE GIVEN WIDTH"""
|
|
abs_values = [abs(v) for v in values]
|
|
total = sum(abs_values)
|
|
if total <= 0:
|
|
return " " * width
|
|
segments = []
|
|
allocated = 0
|
|
for i, v in enumerate(abs_values):
|
|
seg_width = int(round((v / total) * width))
|
|
if i == len(abs_values) - 1:
|
|
seg_width = max(width - allocated, 0)
|
|
allocated += seg_width
|
|
glyph = segment_glyph(i, colour)
|
|
char = glyph * seg_width
|
|
if colour:
|
|
code = ANSI_COLOURS[i % len(ANSI_COLOURS)]
|
|
char = f"\033[{code}m{char}\033[0m"
|
|
segments.append(char)
|
|
return "".join(segments)
|
|
|
|
|
|
def render_legend(labels, values, colour):
|
|
"""RENDER A LABEL AND PERCENTAGE LEGEND"""
|
|
abs_values = [abs(v) for v in values]
|
|
total = sum(abs_values)
|
|
max_label = max((len(l) for l in labels), default=0)
|
|
lines = []
|
|
for i, (label, value) in enumerate(zip(labels, values)):
|
|
pct = (abs(value) / total * 100) if total else 0.0
|
|
glyph = segment_glyph(i, colour)
|
|
swatch = glyph
|
|
if colour:
|
|
code = ANSI_COLOURS[i % len(ANSI_COLOURS)]
|
|
swatch = f"\033[{code}m{glyph}\033[0m"
|
|
lines.append(f"{swatch} {label:<{max_label}} {value:>14.2f} {pct:>5.1f}%")
|
|
return "\n".join(lines)
|
|
|
|
|
|
# MATPLOTLIB HELPERS
|
|
# imported lazily, ascii output never needs this dependency
|
|
|
|
def _matplotlib_pyplot(theme="default"):
|
|
try:
|
|
import matplotlib
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
if theme and theme != "default":
|
|
try:
|
|
plt.style.use(theme)
|
|
except OSError:
|
|
print(f"warning: matplotlib theme '{theme}' not found, falling back to default", file=sys.stderr)
|
|
return plt
|
|
except ImportError:
|
|
print(
|
|
"error: matplotlib not installed, install with "
|
|
"'pacman -S python-matplotlib' or "
|
|
"'pip install matplotlib --break-system-packages'",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
def render_histogram_png(accounts, amounts, path, currency, theme):
|
|
plt = _matplotlib_pyplot(theme)
|
|
data = sorted(zip(accounts, amounts), key=lambda p: abs(p[1]), reverse=True)
|
|
labels = [d[0] for d in data]
|
|
values = [d[1] for d in data]
|
|
fig, ax = plt.subplots(figsize=(10, max(4, len(labels) * 0.4)))
|
|
ax.barh(labels, values, color="#4C72B0")
|
|
ax.invert_yaxis()
|
|
ax.set_xlabel(f"amount ({currency})" if currency else "amount")
|
|
ax.set_title("account balances")
|
|
fig.tight_layout()
|
|
fig.savefig(path, dpi=150)
|
|
fig.clf()
|
|
plt.close(fig)
|
|
print(f"saved histogram to {path}", file=sys.stderr)
|
|
|
|
|
|
# MATPLOTLIB HELPERS
|
|
|
|
def render_pie_png(labels, values, path, currency, theme):
|
|
plt = _matplotlib_pyplot(theme)
|
|
fig, ax = plt.subplots(figsize=(10, 7))
|
|
abs_values = [abs(v) for v in values]
|
|
total = sum(abs_values)
|
|
|
|
# format legend labels with percentages
|
|
legend_labels = [
|
|
f"{label} ({(abs(val) / total * 100):.1f}%)" if total else label
|
|
for label, val in zip(labels, values)
|
|
]
|
|
|
|
# suppress autopct on small slices to prevent overlap
|
|
def autopct_fmt(pct):
|
|
return f"{pct:.1f}%" if pct >= 3.0 else ""
|
|
|
|
wedges, _, autotexts = ax.pie(
|
|
abs_values,
|
|
autopct=autopct_fmt,
|
|
startangle=90,
|
|
pctdistance=0.75,
|
|
)
|
|
|
|
# adjust text contrast based on slice luminance
|
|
for wedge, autotext in zip(wedges, autotexts):
|
|
if autotext.get_text():
|
|
r, g, b = wedge.get_facecolor()[:3]
|
|
luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
|
autotext.set_color("black" if luminance > 0.6 else "white")
|
|
autotext.set_fontsize(9)
|
|
autotext.set_weight("bold")
|
|
|
|
ax.legend(
|
|
wedges,
|
|
legend_labels,
|
|
title="accounts",
|
|
loc="center left",
|
|
bbox_to_anchor=(1, 0, 0.5, 1),
|
|
frameon=False,
|
|
)
|
|
|
|
ax.set_title(
|
|
f"account distribution ({currency})"
|
|
if currency
|
|
else "account distribution"
|
|
)
|
|
ax.axis("equal")
|
|
fig.tight_layout()
|
|
fig.savefig(path, dpi=150, bbox_inches="tight")
|
|
fig.clf()
|
|
plt.close(fig)
|
|
print(f"saved pie chart to {path}", file=sys.stderr)
|
|
|
|
|
|
def render_trend_png(accounts, periods, matrix, path, currency, theme):
|
|
plt = _matplotlib_pyplot(theme)
|
|
fig, ax = plt.subplots(figsize=(10, 6))
|
|
for i, account in enumerate(accounts):
|
|
ax.plot(periods, matrix[i], marker="o", label=account)
|
|
ax.set_xlabel("period")
|
|
ax.set_ylabel(f"amount ({currency})" if currency else "amount")
|
|
ax.set_title("balance trend")
|
|
ax.legend()
|
|
plt.setp(ax.get_xticklabels(), rotation=45, ha="right")
|
|
fig.tight_layout()
|
|
fig.savefig(path, dpi=150)
|
|
fig.clf()
|
|
plt.close(fig)
|
|
print(f"saved trend chart to {path}", file=sys.stderr)
|
|
|
|
|
|
def render_stacked_png(accounts, periods, matrix, path, currency, theme):
|
|
plt = _matplotlib_pyplot(theme)
|
|
fig, ax = plt.subplots(figsize=(10, 6))
|
|
bottom = [0.0] * len(periods)
|
|
for i, account in enumerate(accounts):
|
|
values = [abs(v) for v in matrix[i]]
|
|
ax.bar(periods, values, bottom=bottom, label=account)
|
|
bottom = [b + v for b, v in zip(bottom, values)]
|
|
ax.set_xlabel("period")
|
|
ax.set_ylabel(f"amount ({currency})" if currency else "amount")
|
|
ax.set_title("spending by category per period")
|
|
ax.legend()
|
|
plt.setp(ax.get_xticklabels(), rotation=45, ha="right")
|
|
fig.tight_layout()
|
|
fig.savefig(path, dpi=150)
|
|
fig.clf()
|
|
plt.close(fig)
|
|
print(f"saved stacked chart to {path}", file=sys.stderr)
|
|
|
|
|
|
def render_grouped_png(accounts, periods, matrix, path, currency, theme):
|
|
plt = _matplotlib_pyplot(theme)
|
|
x = list(range(len(periods)))
|
|
n = len(accounts)
|
|
width = 0.8 / max(n, 1)
|
|
fig, ax = plt.subplots(figsize=(10, 6))
|
|
for i, account in enumerate(accounts):
|
|
values = [abs(v) for v in matrix[i]]
|
|
offset = (i - (n - 1) / 2) * width
|
|
positions = [xi + offset for xi in x]
|
|
ax.bar(positions, values, width=width, label=account)
|
|
ax.set_xticks(x)
|
|
ax.set_xticklabels(periods, rotation=45, ha="right")
|
|
ax.set_xlabel("period")
|
|
ax.set_ylabel(f"amount ({currency})" if currency else "amount")
|
|
ax.set_title("monthly comparison by account")
|
|
ax.legend()
|
|
fig.tight_layout()
|
|
fig.savefig(path, dpi=150)
|
|
fig.clf()
|
|
plt.close(fig)
|
|
print(f"saved grouped chart to {path}", file=sys.stderr)
|
|
|
|
|
|
# SUBCOMMANDS
|
|
|
|
def cmd_histogram(args):
|
|
rows = load_csv_rows(args, period=False)
|
|
accounts, amounts = parse_balance_csv(rows, invert=args.invert)
|
|
if not accounts:
|
|
print("error: no account data found in input", file=sys.stderr)
|
|
sys.exit(1)
|
|
accounts, amounts = aggregate_single_period(accounts, amounts, args.depth)
|
|
total = sum(amounts)
|
|
abs_total = sum(abs(a) for a in amounts)
|
|
data = sorted(zip(accounts, amounts), key=lambda p: abs(p[1]), reverse=True)
|
|
max_label = max(len(a) for a, _ in data)
|
|
bar_width = 35
|
|
print(f"\n{'ACCOUNT':<{max_label}} | {'AMOUNT':>14} | {'%':>5} | HISTOGRAM")
|
|
print("-" * (max_label + 62))
|
|
for account, amount in data:
|
|
pct = (abs(amount) / abs_total * 100) if abs_total else 0.0
|
|
filled = int((pct / 100) * bar_width)
|
|
print(f"{account:<{max_label}} | {amount:>14.2f} | {pct:>4.1f}% | {'█' * filled}")
|
|
print("-" * (max_label + 62))
|
|
print(f"{'TOTAL':<{max_label}} | {total:>14.2f} | 100.0% |\n")
|
|
if args.png:
|
|
render_histogram_png(accounts, amounts, args.png, args.currency, args.theme)
|
|
|
|
|
|
def cmd_pie(args):
|
|
rows = load_csv_rows(args, period=False)
|
|
accounts, amounts = parse_balance_csv(rows, invert=args.invert)
|
|
if not accounts:
|
|
print("error: no account data found in input", file=sys.stderr)
|
|
sys.exit(1)
|
|
accounts, amounts = aggregate_single_period(accounts, amounts, args.depth)
|
|
labels, values = top_n_with_other(accounts, amounts, args.top)
|
|
colour = use_colour(args)
|
|
print()
|
|
print(render_stacked_bar(labels, values, args.width, colour))
|
|
print()
|
|
print(render_legend(labels, values, colour))
|
|
print()
|
|
if args.png:
|
|
render_pie_png(labels, values, args.png, args.currency, args.theme)
|
|
|
|
|
|
def cmd_trend(args):
|
|
rows = load_csv_rows(args, period=True)
|
|
accounts, periods, matrix = parse_period_csv(rows, invert=args.invert)
|
|
if not accounts:
|
|
print("error: no account data found in input", file=sys.stderr)
|
|
sys.exit(1)
|
|
accounts, periods, matrix = aggregate_multi_period(accounts, periods, matrix, args.depth)
|
|
if args.top > 0:
|
|
accounts, matrix = top_n_matrix_with_other(accounts, matrix, args.top)
|
|
else:
|
|
# DEFAULT TO A SINGLE AGGREGATE SERIES
|
|
# sums every account per period unless --top requests individual lines
|
|
agg = [sum(row[j] for row in matrix) for j in range(len(periods))]
|
|
accounts, matrix = ["total"], [agg]
|
|
colour = use_colour(args)
|
|
max_label = max(len(a) for a in accounts)
|
|
print()
|
|
for i, account in enumerate(accounts):
|
|
series = matrix[i]
|
|
spark_lines = multiline_sparkline(series, rows=args.rows)
|
|
if colour:
|
|
code = ANSI_COLOURS[i % len(ANSI_COLOURS)]
|
|
spark_lines = [f"\033[{code}m{sl}\033[0m" for sl in spark_lines]
|
|
if args.rows > 1:
|
|
print(f"{account}: {series[0]:>12.2f} -> {series[-1]:>12.2f}")
|
|
for sl in spark_lines:
|
|
print(f" {sl}")
|
|
print()
|
|
else:
|
|
print(f"{account:<{max_label}} {spark_lines[0]} {series[0]:>12.2f} -> {series[-1]:>12.2f}")
|
|
if args.rows <= 1:
|
|
print()
|
|
print("periods: " + ", ".join(periods))
|
|
print()
|
|
if args.png:
|
|
render_trend_png(accounts, periods, matrix, args.png, args.currency, args.theme)
|
|
|
|
|
|
def cmd_stacked(args):
|
|
rows = load_csv_rows(args, period=True)
|
|
accounts, periods, matrix = parse_period_csv(rows, invert=args.invert)
|
|
if not accounts:
|
|
print("error: no account data found in input", file=sys.stderr)
|
|
sys.exit(1)
|
|
accounts, periods, matrix = aggregate_multi_period(accounts, periods, matrix, args.depth)
|
|
accounts, matrix = top_n_matrix_with_other(accounts, matrix, args.top)
|
|
colour = use_colour(args)
|
|
max_period = max(len(p) for p in periods)
|
|
print()
|
|
for j, period in enumerate(periods):
|
|
values = [abs(matrix[i][j]) for i in range(len(accounts))]
|
|
bar = render_stacked_bar(accounts, values, args.width, colour)
|
|
print(f"{period:<{max_period}} {bar}")
|
|
print()
|
|
totals = [sum(abs(matrix[i][j]) for j in range(len(periods))) for i in range(len(accounts))]
|
|
print(render_legend(accounts, totals, colour))
|
|
print()
|
|
if args.png:
|
|
render_stacked_png(accounts, periods, matrix, args.png, args.currency, args.theme)
|
|
|
|
|
|
def cmd_grouped(args):
|
|
rows = load_csv_rows(args, period=True)
|
|
accounts, periods, matrix = parse_period_csv(rows, invert=args.invert)
|
|
if not accounts:
|
|
print("error: no account data found in input", file=sys.stderr)
|
|
sys.exit(1)
|
|
accounts, periods, matrix = aggregate_multi_period(accounts, periods, matrix, args.depth)
|
|
accounts, matrix = top_n_matrix_with_other(accounts, matrix, args.top)
|
|
colour = use_colour(args)
|
|
bar_width = 20
|
|
print()
|
|
for i, account in enumerate(accounts):
|
|
row = [abs(v) for v in matrix[i]]
|
|
row_max = max(row) if row else 0
|
|
print(account)
|
|
for j, period in enumerate(periods):
|
|
filled = int((row[j] / row_max) * bar_width) if row_max else 0
|
|
char = "█" * filled
|
|
if colour:
|
|
code = ANSI_COLOURS[i % len(ANSI_COLOURS)]
|
|
char = f"\033[{code}m{char}\033[0m"
|
|
padded = char + " " * (bar_width - filled)
|
|
print(f" {period:<10} {padded} {row[j]:>12.2f}")
|
|
print()
|
|
if args.png:
|
|
render_grouped_png(accounts, periods, matrix, args.png, args.currency, args.theme)
|
|
|
|
|
|
# ARGUMENT PARSING
|
|
|
|
def build_parser():
|
|
parser = argparse.ArgumentParser(
|
|
description="terminal and image charts for hledger csv output, reads csv from standard input"
|
|
)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
common = argparse.ArgumentParser(add_help=False)
|
|
common.add_argument("--png", metavar="PATH", help="also render a matplotlib png to this path")
|
|
common.add_argument("-t", "--theme", default="default", help="matplotlib stylesheet theme (e.g., dark_background, ggplot)")
|
|
common.add_argument("--currency", default="", help="currency label for chart titles")
|
|
common.add_argument("--no-colour", action="store_true", help="disable ansi colour output")
|
|
common.add_argument("-i", "--invert", action="store_true", help="invert mathematical signs (multiply all amounts by -1)")
|
|
common.add_argument("-d", "--depth", type=int, default=0, help="summarise accounts to this hierarchy depth (0 for full names)")
|
|
common.add_argument("--journal", metavar="PATH", help="hledger journal file, calls hledger directly instead of reading csv from stdin")
|
|
common.add_argument("query", nargs=argparse.REMAINDER, help="optional hledger query passed through to hledger balance, e.g. an account pattern or -b/-e dates, only used with --journal")
|
|
|
|
p_hist = sub.add_parser("histogram", parents=[common], help="horizontal bar histogram of account balances")
|
|
p_hist.set_defaults(func=cmd_histogram)
|
|
|
|
p_pie = sub.add_parser("pie", parents=[common], help="proportional bar and pie chart of account distribution")
|
|
p_pie.add_argument("--top", type=int, default=8, help="accounts to show before collapsing into other, 0 for no limit")
|
|
p_pie.add_argument("--width", type=int, default=60, help="width of the ascii bar in characters")
|
|
p_pie.set_defaults(func=cmd_pie)
|
|
|
|
p_trend = sub.add_parser("trend", parents=[common], help="balance trend over time as sparklines and a line chart")
|
|
p_trend.add_argument("--top", type=int, default=0, help="individual account lines to show, 0 shows only the total")
|
|
p_trend.add_argument("-r", "--rows", type=int, default=1, help="height of the ascii sparkline canvas in rows (default 1)")
|
|
p_trend.set_defaults(func=cmd_trend)
|
|
|
|
p_stack = sub.add_parser("stacked", parents=[common], help="stacked bar of spending by category per period")
|
|
p_stack.add_argument("--top", type=int, default=8, help="accounts to show before collapsing into other, 0 for no limit")
|
|
p_stack.add_argument("--width", type=int, default=50, help="width of each ascii bar in characters")
|
|
p_stack.set_defaults(func=cmd_stacked)
|
|
|
|
p_group = sub.add_parser("grouped", parents=[common], help="grouped bar comparison of accounts across periods")
|
|
p_group.add_argument("--top", type=int, default=6, help="accounts to show before collapsing into other, 0 for no limit")
|
|
p_group.set_defaults(func=cmd_grouped)
|
|
|
|
return parser
|
|
|
|
|
|
def main():
|
|
parser = build_parser()
|
|
args = parser.parse_args()
|
|
args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|