Skip to content

Commit 03c077b

Browse files
committed
Implement LedgerLite JSON expense tracker
1 parent 35c09df commit 03c077b

1 file changed

Lines changed: 102 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#!/usr/bin/env python3
2+
"""A tiny JSON-backed command-line expense tracker."""
3+
4+
import argparse
5+
import json
6+
import os
7+
import tempfile
8+
from datetime import datetime, timezone
9+
from pathlib import Path
10+
11+
12+
def load_entries(path: Path) -> list[dict]:
13+
if not path.exists():
14+
return []
15+
try:
16+
data = json.loads(path.read_text(encoding="utf-8"))
17+
except (OSError, json.JSONDecodeError) as exc:
18+
raise ValueError(f"cannot read data file {path}: {exc}") from exc
19+
if not isinstance(data, list):
20+
raise ValueError("data file must contain a JSON list")
21+
return data
22+
23+
24+
def save_entries(path: Path, entries: list[dict]) -> None:
25+
path.parent.mkdir(parents=True, exist_ok=True)
26+
fd, temp_name = tempfile.mkstemp(prefix=path.name, dir=path.parent, text=True)
27+
try:
28+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
29+
json.dump(entries, handle, indent=2, ensure_ascii=False)
30+
handle.write("\n")
31+
os.replace(temp_name, path)
32+
except Exception:
33+
try:
34+
os.unlink(temp_name)
35+
except FileNotFoundError:
36+
pass
37+
raise
38+
39+
40+
def add_entry(entries: list[dict], amount: float, description: str, category: str) -> dict:
41+
description = description.strip()
42+
category = category.strip()
43+
if amount <= 0:
44+
raise ValueError("amount must be greater than zero")
45+
if not description:
46+
raise ValueError("description cannot be empty")
47+
if not category:
48+
raise ValueError("category cannot be empty")
49+
entry = {
50+
"amount": round(amount, 2),
51+
"description": description,
52+
"category": category,
53+
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
54+
}
55+
entries.append(entry)
56+
return entry
57+
58+
59+
def build_parser() -> argparse.ArgumentParser:
60+
parser = argparse.ArgumentParser(description=__doc__)
61+
parser.add_argument("--file", type=Path, default=Path("ledgerlite.json"))
62+
commands = parser.add_subparsers(dest="command", required=True)
63+
add = commands.add_parser("add", help="record an expense")
64+
add.add_argument("amount", type=float)
65+
add.add_argument("description")
66+
add.add_argument("--category", default="uncategorized")
67+
commands.add_parser("list", help="list all expenses")
68+
commands.add_parser("summary", help="summarize spending by category")
69+
return parser
70+
71+
72+
def main() -> int:
73+
parser = build_parser()
74+
args = parser.parse_args()
75+
try:
76+
entries = load_entries(args.file)
77+
if args.command == "add":
78+
entry = add_entry(entries, args.amount, args.description, args.category)
79+
save_entries(args.file, entries)
80+
print(f"Added {entry['amount']:.2f}: {entry['description']} [{entry['category']}]")
81+
elif args.command == "list":
82+
if not entries:
83+
print("No expenses recorded.")
84+
for index, entry in enumerate(entries, start=1):
85+
print(f"{index:>3}. {entry['amount']:>9.2f} {entry['category']:<14} {entry['description']}")
86+
else:
87+
totals: dict[str, float] = {}
88+
for entry in entries:
89+
totals[entry["category"]] = totals.get(entry["category"], 0.0) + float(entry["amount"])
90+
if not totals:
91+
print("No expenses recorded.")
92+
for category, total in sorted(totals.items()):
93+
print(f"{category:<18} {total:>9.2f}")
94+
print(f"{'TOTAL':<18} {sum(totals.values()):>9.2f}")
95+
except (OSError, ValueError, KeyError, TypeError) as exc:
96+
print(f"Error: {exc}")
97+
return 1
98+
return 0
99+
100+
101+
if __name__ == "__main__":
102+
raise SystemExit(main())

0 commit comments

Comments
 (0)