Ledgerly/ledgerly/cli.py
2026-10-27 11:04:50 -05:00

253 lines
8.4 KiB
Python

"""Command line interface: parsing, dispatch, and text presentation.
Handlers stay pure with respect to I/O: each returns the text to print (or
None). ``main`` is the only place that talks to stdout/stderr (D7), which
keeps the CLI trivially testable.
"""
from __future__ import annotations
import argparse
import sys
from datetime import date
from pathlib import Path
from typing import Callable, Sequence
from . import __version__
from .errors import LedgerlyError, ValidationError
from .money import InvalidMoney, Money
from .models import EXPENSE, INCOME, Month, Transaction, parse_date
from .repository import SqliteRepository
from .services import BudgetService, LedgerService, ReportingService
__all__ = ["build_parser", "default_db_path", "main"]
def default_db_path() -> Path:
return Path.home() / ".ledgerly" / "ledgerly.db"
# -- argument types ----------------------------------------------------------
def money_arg(text: str) -> Money:
try:
return Money.parse(text)
except InvalidMoney as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
def date_arg(text: str) -> date:
try:
return parse_date(text)
except ValidationError as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
def month_arg(text: str) -> Month:
try:
return Month.from_str(text)
except ValidationError as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
# -- parser -------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="ledgerly",
description=(
"A tiny personal finance tracker: transactions, budgets, "
"monthly reports."
),
)
parser.add_argument(
"--version", action="version", version=f"ledgerly {__version__}"
)
parser.add_argument(
"--db",
default=None,
metavar="PATH",
help=f"database file (default: {default_db_path()})",
)
sub = parser.add_subparsers(dest="command", required=True)
add = sub.add_parser("add", help="record a transaction")
add.add_argument("amount", type=money_arg)
add.add_argument("--category", required=True)
add.add_argument("--date", type=date_arg, default=None, help="YYYY-MM-DD (default: today)")
add.add_argument("--note", default=None)
lst = sub.add_parser("list", help="list transactions")
lst.add_argument("--month", type=month_arg, default=None, help="YYYY-MM (default: current)")
lst.add_argument("--category", default=None)
lst.add_argument("--all", action="store_true", help="ignore the month filter")
delete = sub.add_parser("delete", help="delete a transaction by id")
delete.add_argument("id", type=int)
sub.add_parser("categories", help="list categories")
cat_add = sub.add_parser("category-add", help="add a category")
cat_add.add_argument("name")
cat_add.add_argument("--type", dest="kind", required=True, choices=[INCOME, EXPENSE])
cat_del = sub.add_parser("category-del", help="delete an unused category")
cat_del.add_argument("name")
budget_set = sub.add_parser("budget-set", help="set the monthly budget for an expense category")
budget_set.add_argument("category")
budget_set.add_argument("amount", type=money_arg)
budget_del = sub.add_parser("budget-del", help="remove a budget")
budget_del.add_argument("category")
budget_show = sub.add_parser("budget-show", help="budgets vs. spending for a month")
budget_show.add_argument("--month", type=month_arg, default=None, help="YYYY-MM (default: current)")
report = sub.add_parser("report", help="monthly summary and category breakdown")
report.add_argument("--month", type=month_arg, default=None, help="YYYY-MM (default: current)")
return parser
# -- command handlers (return the text to print) ------------------------------
def _cmd_add(args: argparse.Namespace, repo: SqliteRepository) -> str:
service = LedgerService(repo)
day = args.date or date.today()
tx = service.add_transaction(args.amount, args.category, day, args.note)
note = f" ({tx.note})" if tx.note else ""
return (
f"recorded #{tx.id}: {tx.amount} in {tx.category} "
f"on {tx.date.isoformat()}{note}"
)
def _cmd_list(args: argparse.Namespace, repo: SqliteRepository) -> str:
service = LedgerService(repo)
if args.all:
start = end = None
else:
start, end = (args.month or Month.now()).range
rows = service.list_transactions(start, end, args.category)
return _render_transactions(rows)
def _render_transactions(rows: list[Transaction]) -> str:
if not rows:
return "no transactions"
out = []
for tx in rows:
line = f"#{tx.id:<4} {tx.date.isoformat()} {tx.category:<16} {tx.kind:<8} {tx.amount}"
if tx.note:
line += f" [{tx.note}]"
out.append(line)
return "\n".join(out)
def _cmd_delete(args: argparse.Namespace, repo: SqliteRepository) -> str:
LedgerService(repo).delete_transaction(args.id)
return f"deleted transaction #{args.id}"
def _cmd_categories(args: argparse.Namespace, repo: SqliteRepository) -> str:
cats = LedgerService(repo).list_categories()
if not cats:
return "no categories"
return "\n".join(f"{cat.kind:<8} {cat.name}" for cat in cats)
def _cmd_category_add(args: argparse.Namespace, repo: SqliteRepository) -> str:
cat = LedgerService(repo).add_category(args.name, args.kind)
return f"added {cat.kind} category {cat.name!r}"
def _cmd_category_del(args: argparse.Namespace, repo: SqliteRepository) -> str:
LedgerService(repo).delete_category(args.name)
return f"deleted category {args.name!r}"
def _cmd_budget_set(args: argparse.Namespace, repo: SqliteRepository) -> str:
budget = BudgetService(repo).set_budget(args.category, args.amount)
return f"budget for {budget.category}: {budget.amount} per month"
def _cmd_budget_del(args: argparse.Namespace, repo: SqliteRepository) -> str:
budget = BudgetService(repo).delete_budget(args.category)
return f"removed budget for {budget.category}: {budget.amount}"
def _cmd_budget_show(args: argparse.Namespace, repo: SqliteRepository) -> str:
month = args.month or Month.now()
lines = ReportingService(repo).budget_status(month)
out = [f"budgets for {month.label}"]
if not lines:
out.append("no budgets set, no unbudgeted spending")
for line in lines:
marker = " OVER" if line.is_over else ""
out.append(
f"{line.category:<16} budget {line.budget:<12} "
f"spent {line.spent:<12} left {line.remaining}{marker}"
)
return "\n".join(out)
def _cmd_report(args: argparse.Namespace, repo: SqliteRepository) -> str:
month = args.month or Month.now()
service = ReportingService(repo)
summary = service.monthly_summary(month)
out = [
f"report for {month.label}",
f" income {summary.income}",
f" expenses {summary.expenses}",
f" net {summary.net}",
]
totals = service.category_totals(month)
if not totals:
out.append(" no transactions this month")
else:
current_kind = None
for total in totals:
if total.kind != current_kind:
out.append(f" {total.kind}")
current_kind = total.kind
out.append(f" {total.category:<16} {total.total}")
return "\n".join(out)
HANDLERS: dict[str, Callable[[argparse.Namespace, SqliteRepository], str]] = {
"add": _cmd_add,
"list": _cmd_list,
"delete": _cmd_delete,
"categories": _cmd_categories,
"category-add": _cmd_category_add,
"category-del": _cmd_category_del,
"budget-set": _cmd_budget_set,
"budget-del": _cmd_budget_del,
"budget-show": _cmd_budget_show,
"report": _cmd_report,
}
# -- entry point ---------------------------------------------------------------
def main(argv: Sequence[str] | None = None, *, db_path: str | Path | None = None) -> int:
"""Run the CLI. Returns a process exit code (SPECS.md §7)."""
args = build_parser().parse_args(argv)
path = str(db_path) if db_path is not None else str(args.db or default_db_path())
repo = SqliteRepository(path)
try:
output = HANDLERS[args.command](args, repo)
except LedgerlyError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
finally:
repo.close()
if output:
print(output)
return 0