// Beginner project · about 5 hours of work
Expense tracker
You build a small command interpreter that keeps your expenses in expenses.json. It reads commands such as add 2026-09-12 food 12.50 lunch, list 2026-09 and summary 2026-09 from its input, and prints a line per expense or a total per category. Amounts are stored as whole cents in an int, because a float cannot hold 0.10 exactly and totals would drift. A missing file means you start empty; a corrupt one stops the program with a clear message and is left untouched, so nothing you typed is lost. It uses everything from the Beginner level: functions, lists, dicts, f-strings, files, JSON and exceptions.
What the finished program does
- Commands come one per line from standard input: add DATE CATEGORY AMOUNT [NOTE], list [MONTH], summary [MONTH], help and quit. Empty lines are skipped.
- Amounts are written like 12, 12.5 or 12.50 with ASCII digits and stored as whole cents (an int). Negative, zero, text, other digits such as 0 and more than two decimals are rejected.
- Dates are written YYYY-MM-DD and must exist (no 2026-02-31; 29 February only in leap years); months are written YYYY-MM. Categories are stored in lower case; the note is everything after the amount.
- Every add is saved at once to expenses.json as a JSON list of objects with date, category, cents and note, indented for reading.
- list prints one line per expense, oldest first: the date, the category padded to 12 characters, the amount right-aligned in 10, then the note.
- summary prints one line per category in alphabetical order, then a total line; with no matching expenses both commands print No expenses.
- A bad command or value prints one line starting Error: (or a hint to type help) and changes nothing.
- A missing expenses.json starts an empty list. A file that is not valid JSON, or not a list of expenses, prints an error naming the file and stops without writing it.
Starter layout
- main.py
- The whole program: parsing and formatting money, loading and saving the JSON file, filtering and totals, the command handler and main(). The date and month checks (with the leap-year rule) and the shape check _looks_like_expense are written for you; the rest are stubs marked TODO.
Milestones
Milestone 1
Money as whole cents
Write parse_amount and format_cents. Split the text at the dot with str.partition, check the digits with isascii and isdigit (isdigit alone accepts full-width digits), and build the cents with int arithmetic only, never float.
Checks that pass once this milestone is done:
- parse_amount turns "12.50", "12.5" and "7" into whole cents
- parse_amount raises ValueError for abc, -3, 1.234, 0 and an empty string
- parse_amount raises ValueError for full-width digits such as 0.50 and 12
- format_cents shows 1250 as 12.50 and 5 as 0.05
Milestone 2
Load the file safely
Write load_expenses and save_expenses with with open(...). Catch FileNotFoundError to start empty, and turn json.JSONDecodeError, or data that is not a list of items passing the provided _looks_like_expense, into a ValueError naming the file.
Checks that pass once this milestone is done:
- load_expenses returns [] when the file does not exist
- load_expenses raises ValueError naming the file when it is not valid JSON
- load_expenses raises ValueError when the JSON is not a list of expenses
Milestone 3
Filter and total
Write in_month, which keeps one month and sorts by date, and totals_by_category, which adds cents per category in a dict and returns it sorted by category.
Checks that pass once this milestone is done:
- totals_by_category adds up cents per category, in alphabetical order
- in_month keeps one month's expenses, oldest first
Milestone 4
The add command
In handle, split the line into words, build an expense with make_expense and append it. Catch ValueError and return a line starting Error:, so a typo never crashes the program. The provided check_date already rejects 31 February.
Checks that pass once this milestone is done:
- add prints a confirmation and saves the amount as cents in expenses.json
- A bad amount, a bad date or an unknown command prints an error and saves nothing
- add rejects 2026-02-31 and 2026-02-29 but accepts 2024-02-29, a leap day
Milestone 5
Reports and a corrupt file
Write list_lines and summary_lines with f-string widths, wire list and summary into handle, and make main print the loading error and stop before it reads any command.
Checks that pass once this milestone is done:
- list 2026-09 prints that month's expenses, oldest first, and data survives between runs
- summary 2026-09 prints a total per category and a total line
- With a corrupt expenses.json the program prints an error and leaves the file as it was
Build it in the browser
Edit main.py below; the other starter files stay as they are. “Check my code” runs every acceptance test, so expect failures until the last milestone.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Build it on your computer
Make a folder with these starter files and Python 3.14, then work through the milestones. Run the acceptance tests at any point with:
Download the starter as one .zip (starter files, test_main.py and learnrun.py)python learnrun.py testOn macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Download learnrun.pymain.py
"""Expense tracker: add, list and summarise expenses kept in a JSON file.
Reads one command per line from standard input:
add 2026-09-12 food 12.50 lunch
list 2026-09
summary 2026-09
Amounts are stored as whole cents (int), so totals are exact.
"""
import json
import sys
DATA_FILE = "expenses.json"
HELP = """Commands:
add DATE CATEGORY AMOUNT [NOTE] e.g. add 2026-09-12 food 12.50 lunch
list [MONTH] e.g. list 2026-09
summary [MONTH] totals per category
help this text
quit stop reading commands"""
def parse_amount(text: str) -> int:
"""Turn "12.50" into 1250 cents. Raise ValueError for anything else."""
# TODO: accept "12", "12.5" and "12.50"; reject "-3", "1.234", "abc", "", 0
# and full-width digits such as "12" (isdigit accepts them; check isascii too).
# Hint: text.partition(".") splits "12.50" into ("12", ".", "50").
raise NotImplementedError
def format_cents(cents: int) -> str:
"""Turn 1250 into "12.50"."""
# TODO: use // and % with an f-string; 5 cents is "0.05".
raise NotImplementedError
DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
def is_leap_year(year: int) -> bool:
"""Every 4th year is a leap year, except every 100th, except every 400th."""
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
def check_date(text: str) -> str:
"""Return text if it is a real date written YYYY-MM-DD, else raise ValueError."""
parts = text.split("-")
if len(parts) != 3 or [len(p) for p in parts] != [4, 2, 2] or not all(p.isascii() and p.isdigit() for p in parts):
raise ValueError(f"date must look like 2026-09-12, got {text!r}")
year, month, day = int(parts[0]), int(parts[1]), int(parts[2])
if not 1 <= month <= 12:
raise ValueError(f"no such date: {text!r}")
last_day = DAYS_IN_MONTH[month - 1]
if month == 2 and is_leap_year(year):
last_day = 29
if not 1 <= day <= last_day:
raise ValueError(f"no such date: {text!r}")
return text
def check_month(text: str) -> str:
"""Return text if it is a month written YYYY-MM, else raise ValueError."""
parts = text.split("-")
if len(parts) != 2 or [len(p) for p in parts] != [4, 2] or not all(p.isascii() and p.isdigit() for p in parts):
raise ValueError(f"month must look like 2026-09, got {text!r}")
if not 1 <= int(parts[1]) <= 12:
raise ValueError(f"no such month: {text!r}")
return text
def make_expense(date: str, category: str, cents: int, note: str = "") -> dict:
"""Build one expense record, the shape stored in the JSON file."""
return {"date": check_date(date), "category": category.lower(), "cents": cents, "note": note}
def in_month(expenses: list[dict], month: str | None) -> list[dict]:
"""The expenses of one month ("2026-09"), or all of them for None, sorted by date."""
# TODO: filter on e["date"], then sort by date.
raise NotImplementedError
def totals_by_category(expenses: list[dict]) -> dict[str, int]:
"""Total cents per category, categories in alphabetical order."""
# TODO: add up e["cents"] per e["category"] in a dict.
raise NotImplementedError
def load_expenses(path: str) -> list[dict]:
"""Read the expenses from path. A missing file means no expenses yet.
Raise ValueError with a clear message if the file is not a list of expenses.
"""
# TODO: open with `with open(...)`, json.load, and handle
# FileNotFoundError (return []) and json.JSONDecodeError (raise ValueError naming path).
# Then raise ValueError naming path unless the data is a list and
# _looks_like_expense(item) is True for every item (all(...) helps).
raise NotImplementedError
def _looks_like_expense(item: object) -> bool:
"""True if item is a dict with a str date and category, an int cents and a str note."""
if not isinstance(item, dict):
return False
return (
isinstance(item.get("date"), str)
and isinstance(item.get("category"), str)
and isinstance(item.get("cents"), int)
and isinstance(item.get("note", ""), str)
)
def save_expenses(path: str, expenses: list[dict]) -> None:
"""Write the expenses to path as indented JSON."""
# TODO: json.dump with indent=2.
raise NotImplementedError
def list_lines(expenses: list[dict], month: str | None) -> list[str]:
"""One line per expense, oldest first, or ["No expenses."]."""
# TODO: f"{date} {category:<12}{amount:>10} {note}", without trailing spaces.
raise NotImplementedError
def summary_lines(expenses: list[dict], month: str | None) -> list[str]:
"""One line per category, then the total, or ["No expenses."]."""
# TODO: f"{category:<12}{amount:>10}" per category, then a line for "total".
raise NotImplementedError
def handle(line: str, expenses: list[dict]) -> list[str]:
"""Run one command line against expenses (add changes the list). Return what to print."""
words = line.split()
command, args = words[0].lower(), words[1:]
# TODO: add, list, summary and help. Turn a ValueError into ["Error: ..."].
return [f"Unknown command {command!r}. Type help for the commands."]
def main(path: str = DATA_FILE) -> None:
"""Read commands from standard input until the input ends or quit."""
# TODO: if load_expenses raises ValueError, print "Error: ..." and stop
# without touching the file.
expenses = load_expenses(path)
for line in sys.stdin:
if not line.strip():
continue
if line.strip().lower() == "quit":
break
before = len(expenses)
for out in handle(line, expenses):
print(out)
if len(expenses) != before:
save_expenses(path, expenses)
if __name__ == "__main__":
main()
Acceptance tests
The project is done when every check in test_main.py passes. Read them before you start: they are the spec, written as code.
test_main.py
import json
import os
import tempfile
from learnrun import run_main
from main import format_cents, load_expenses, parse_amount, totals_by_category, in_month
DATA = "expenses.json"
SAMPLE = [
{"date": "2026-09-12", "category": "food", "cents": 1250, "note": "lunch"},
{"date": "2026-08-30", "category": "rent", "cents": 80000, "note": ""},
{"date": "2026-09-03", "category": "transport", "cents": 490, "note": "bus"},
{"date": "2026-09-20", "category": "food", "cents": 3105, "note": "groceries"},
]
def fresh(content=None):
"""Start a run_main test from no data file, or from one holding content."""
if os.path.exists(DATA):
os.remove(DATA)
if content is not None:
with open(DATA, "w", encoding="utf-8") as f:
f.write(content)
def test_parse_amount():
"""parse_amount turns "12.50", "12.5" and "7" into whole cents"""
got = parse_amount("12.50"), parse_amount("12.5"), parse_amount("7")
assert got == (1250, 1250, 700), f"parse_amount gave {got!r}, expected (1250, 1250, 700)"
def test_parse_amount_rejects():
"""parse_amount raises ValueError for abc, -3, 1.234, 0 and an empty string"""
for text in ["abc", "-3", "1.234", "0", ""]:
try:
got = parse_amount(text)
except ValueError:
continue
raise AssertionError(f"parse_amount({text!r}) returned {got!r}, expected a ValueError")
def test_parse_amount_rejects_wide_digits():
"""parse_amount raises ValueError for full-width digits such as 0.50 and 12"""
for text in ["0.50", "12"]:
try:
got = parse_amount(text)
except ValueError:
continue
raise AssertionError(f"parse_amount({text!r}) returned {got!r}, expected a ValueError (only ASCII digits 0-9 are amounts)")
def test_format_cents():
"""format_cents shows 1250 as 12.50 and 5 as 0.05"""
got = format_cents(1250), format_cents(5), format_cents(80000)
assert got == ("12.50", "0.05", "800.00"), f"format_cents gave {got!r}, expected ('12.50', '0.05', '800.00')"
def test_missing_file_is_empty():
"""load_expenses returns [] when the file does not exist"""
with tempfile.TemporaryDirectory() as folder:
got = load_expenses(os.path.join(folder, "nothing-here.json"))
assert got == [], f"for a missing file load_expenses returned {got!r}, expected []"
def test_corrupt_file_raises():
"""load_expenses raises ValueError naming the file when it is not valid JSON"""
with tempfile.TemporaryDirectory() as folder:
path = os.path.join(folder, "broken.json")
with open(path, "w", encoding="utf-8") as f:
f.write('[{"date": "2026-09-12", ')
try:
got = load_expenses(path)
except ValueError as err:
assert "broken.json" in str(err), f"the error message {str(err)!r} should name the file broken.json"
return
raise AssertionError(f"for a corrupt file load_expenses returned {got!r}, expected a ValueError")
def test_wrong_shape_raises():
"""load_expenses raises ValueError when the JSON is not a list of expenses"""
with tempfile.TemporaryDirectory() as folder:
path = os.path.join(folder, "other.json")
with open(path, "w", encoding="utf-8") as f:
f.write('{"food": 12.5}')
try:
got = load_expenses(path)
except ValueError:
return
raise AssertionError(f"for {{'food': 12.5}} load_expenses returned {got!r}, expected a ValueError")
def test_totals_by_category():
"""totals_by_category adds up cents per category, in alphabetical order"""
got = totals_by_category(SAMPLE)
want = {"food": 4355, "rent": 80000, "transport": 490}
assert got == want, f"totals_by_category gave {got!r}, expected {want!r}"
assert list(got) == ["food", "rent", "transport"], f"the categories came in the order {list(got)!r}, expected alphabetical"
def test_in_month():
"""in_month keeps one month's expenses, oldest first"""
got = [e["date"] for e in in_month(SAMPLE, "2026-09")]
assert got == ["2026-09-03", "2026-09-12", "2026-09-20"], f"in_month(SAMPLE, '2026-09') gave the dates {got!r}"
got_all = len(in_month(SAMPLE, None))
assert got_all == 4, f"in_month(SAMPLE, None) kept {got_all} expenses, expected all 4"
def test_add_saves_cents():
"""add prints a confirmation and saves the amount as cents in expenses.json"""
fresh()
out = run_main("add 2026-09-12 food 12.50 lunch with Sam\n")
assert out.strip() == "Added 12.50 food on 2026-09-12.", f"add printed {out.strip()!r}, expected 'Added 12.50 food on 2026-09-12.'"
with open(DATA, encoding="utf-8") as f:
saved = json.load(f)
want = [{"date": "2026-09-12", "category": "food", "cents": 1250, "note": "lunch with Sam"}]
assert saved == want, f"expenses.json holds {saved!r}, expected {want!r}"
def test_bad_input_is_reported():
"""A bad amount, a bad date or an unknown command prints an error and saves nothing"""
fresh()
out = run_main("add 2026-09-12 food twelve\nadd 2026-13-01 food 3\nspend 5\n").splitlines()
assert len(out) == 3, f"expected one line per bad command, got {out!r}"
assert out[0].startswith("Error:") and out[1].startswith("Error:"), f"the bad add commands printed {out[:2]!r}, expected lines starting 'Error:'"
assert "help" in out[2], f"the unknown command printed {out[2]!r}, expected a hint to type help"
assert not os.path.exists(DATA), "expenses.json was written although nothing was added"
def test_impossible_dates():
"""add rejects 2026-02-31 and 2026-02-29 but accepts 2024-02-29, a leap day"""
fresh()
out = run_main("add 2026-02-31 food 1 x\nadd 2026-02-29 food 1 y\nadd 2024-02-29 food 1 z\n").splitlines()
assert len(out) == 3 and out[0].startswith("Error:") and out[1].startswith("Error:"), f"the impossible dates printed {out[:2]!r}, expected lines starting 'Error:'"
assert out[2] == "Added 1.00 food on 2024-02-29.", f"the leap day printed {out[2]!r}, expected 'Added 1.00 food on 2024-02-29.'"
with open(DATA, encoding="utf-8") as f:
saved = [e["date"] for e in json.load(f)]
assert saved == ["2024-02-29"], f"expenses.json holds the dates {saved!r}, expected only ['2024-02-29']"
def test_list_month():
"""list 2026-09 prints that month's expenses, oldest first, and data survives between runs"""
fresh()
run_main("add 2026-09-12 food 12.50 lunch\nadd 2026-08-30 rent 800\n")
run_main("add 2026-09-03 transport 4.90 bus\n")
out = run_main("list 2026-09\n").splitlines()
want = ["2026-09-03 transport 4.90 bus", "2026-09-12 food 12.50 lunch"]
assert out == want, f"list 2026-09 printed {out!r}, expected {want!r}"
def test_summary():
"""summary 2026-09 prints a total per category and a total line"""
fresh(json.dumps(SAMPLE))
out = run_main("summary 2026-09\n").splitlines()
want = ["food 43.55", "transport 4.90", "total 48.45"]
assert out == want, f"summary 2026-09 printed {out!r}, expected {want!r}"
out = run_main("summary 2025-01\n").splitlines()
assert out == ["No expenses."], f"summary for an empty month printed {out!r}, expected ['No expenses.']"
def test_corrupt_file_kept():
"""With a corrupt expenses.json the program prints an error and leaves the file as it was"""
broken = '[{"date": "2026-09-12", '
fresh(broken)
out = run_main("add 2026-09-13 food 5\n")
assert out.startswith("Error:") and DATA in out, f"the program printed {out.strip()!r}, expected an error naming {DATA}"
with open(DATA, encoding="utf-8") as f:
kept = f.read()
fresh()
assert kept == broken, f"expenses.json now holds {kept!r}, expected it unchanged"
Run the finished program
python main.pyReference solution
Try the milestones first. This solution passes every acceptance test and the type checker.
main.py
"""Expense tracker: add, list and summarise expenses kept in a JSON file.
Reads one command per line from standard input:
add 2026-09-12 food 12.50 lunch
list 2026-09
summary 2026-09
Amounts are stored as whole cents (int), so totals are exact.
"""
import json
import sys
DATA_FILE = "expenses.json"
HELP = """Commands:
add DATE CATEGORY AMOUNT [NOTE] e.g. add 2026-09-12 food 12.50 lunch
list [MONTH] e.g. list 2026-09
summary [MONTH] totals per category
help this text
quit stop reading commands"""
def parse_amount(text: str) -> int:
"""Turn "12.50" into 1250 cents. Raise ValueError for anything else."""
whole, dot, fraction = text.partition(".")
digits_ok = text.isascii() and whole.isdigit() and (not dot or fraction.isdigit())
if not digits_ok or len(fraction) > 2:
raise ValueError(f"amount must look like 12 or 12.50, got {text!r}")
cents = int(whole) * 100 + int(fraction.ljust(2, "0") or "0")
if cents == 0:
raise ValueError("amount must be more than 0")
return cents
def format_cents(cents: int) -> str:
"""Turn 1250 into "12.50"."""
return f"{cents // 100}.{cents % 100:02d}"
DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
def is_leap_year(year: int) -> bool:
"""Every 4th year is a leap year, except every 100th, except every 400th."""
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
def check_date(text: str) -> str:
"""Return text if it is a real date written YYYY-MM-DD, else raise ValueError."""
parts = text.split("-")
if len(parts) != 3 or [len(p) for p in parts] != [4, 2, 2] or not all(p.isascii() and p.isdigit() for p in parts):
raise ValueError(f"date must look like 2026-09-12, got {text!r}")
year, month, day = int(parts[0]), int(parts[1]), int(parts[2])
if not 1 <= month <= 12:
raise ValueError(f"no such date: {text!r}")
last_day = DAYS_IN_MONTH[month - 1]
if month == 2 and is_leap_year(year):
last_day = 29
if not 1 <= day <= last_day:
raise ValueError(f"no such date: {text!r}")
return text
def check_month(text: str) -> str:
"""Return text if it is a month written YYYY-MM, else raise ValueError."""
parts = text.split("-")
if len(parts) != 2 or [len(p) for p in parts] != [4, 2] or not all(p.isascii() and p.isdigit() for p in parts):
raise ValueError(f"month must look like 2026-09, got {text!r}")
if not 1 <= int(parts[1]) <= 12:
raise ValueError(f"no such month: {text!r}")
return text
def make_expense(date: str, category: str, cents: int, note: str = "") -> dict:
"""Build one expense record, the shape stored in the JSON file."""
return {"date": check_date(date), "category": category.lower(), "cents": cents, "note": note}
def in_month(expenses: list[dict], month: str | None) -> list[dict]:
"""The expenses of one month ("2026-09"), or all of them for None, sorted by date."""
chosen = expenses if month is None else [e for e in expenses if e["date"].startswith(month + "-")]
return sorted(chosen, key=lambda e: e["date"])
def totals_by_category(expenses: list[dict]) -> dict[str, int]:
"""Total cents per category, categories in alphabetical order."""
totals: dict[str, int] = {}
for expense in expenses:
totals[expense["category"]] = totals.get(expense["category"], 0) + expense["cents"]
return dict(sorted(totals.items()))
def load_expenses(path: str) -> list[dict]:
"""Read the expenses from path. A missing file means no expenses yet.
Raise ValueError with a clear message if the file is not a list of expenses.
"""
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
return []
except json.JSONDecodeError as err:
raise ValueError(f"{path} is not valid JSON (line {err.lineno}, column {err.colno})") from err
if not isinstance(data, list) or not all(_looks_like_expense(item) for item in data):
raise ValueError(f"{path} does not hold a list of expenses")
return data
def _looks_like_expense(item: object) -> bool:
"""True if item is a dict with a str date and category, an int cents and a str note."""
if not isinstance(item, dict):
return False
return (
isinstance(item.get("date"), str)
and isinstance(item.get("category"), str)
and isinstance(item.get("cents"), int)
and isinstance(item.get("note", ""), str)
)
def save_expenses(path: str, expenses: list[dict]) -> None:
"""Write the expenses to path as indented JSON."""
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, indent=2)
f.write("\n")
def list_lines(expenses: list[dict], month: str | None) -> list[str]:
"""One line per expense, oldest first."""
chosen = in_month(expenses, month)
if not chosen:
return ["No expenses."]
lines = []
for e in chosen:
lines.append(f"{e['date']} {e['category']:<12}{format_cents(e['cents']):>10} {e['note']}".rstrip())
return lines
def summary_lines(expenses: list[dict], month: str | None) -> list[str]:
"""One line per category, then the total."""
chosen = in_month(expenses, month)
if not chosen:
return ["No expenses."]
lines = [f"{category:<12}{format_cents(cents):>10}" for category, cents in totals_by_category(chosen).items()]
total = sum(e["cents"] for e in chosen)
lines.append(f"{'total':<12}{format_cents(total):>10}")
return lines
def handle(line: str, expenses: list[dict]) -> list[str]:
"""Run one command line against expenses (add changes the list). Return what to print."""
words = line.split()
command, args = words[0].lower(), words[1:]
try:
if command == "add":
if len(args) < 3:
return ["Usage: add DATE CATEGORY AMOUNT [NOTE]"]
expense = make_expense(args[0], args[1], parse_amount(args[2]), " ".join(args[3:]))
expenses.append(expense)
return [f"Added {format_cents(expense['cents'])} {expense['category']} on {expense['date']}."]
if command in ("list", "summary"):
if len(args) > 1:
return [f"Usage: {command} [MONTH]"]
month = check_month(args[0]) if args else None
return list_lines(expenses, month) if command == "list" else summary_lines(expenses, month)
if command == "help":
return HELP.splitlines()
except ValueError as err:
return [f"Error: {err}"]
return [f"Unknown command {command!r}. Type help for the commands."]
def main(path: str = DATA_FILE) -> None:
"""Read commands from standard input until the input ends or quit."""
try:
expenses = load_expenses(path)
except ValueError as err:
print(f"Error: {err}. Fix or move the file; it was not changed.")
return
for line in sys.stdin:
if not line.strip():
continue
if line.strip().lower() == "quit":
break
before = len(expenses)
for out in handle(line, expenses):
print(out)
if len(expenses) != before:
save_expenses(path, expenses)
if __name__ == "__main__":
main()
Take it further
- Add a delete N command that removes the Nth expense of the last list, and think about what N should mean when the list was filtered by month.
- Add a budget per category, stored in a second JSON file, and make summary show how much of each budget is left.
- Write the data to a temporary file first and then replace expenses.json with os.replace, so a crash in the middle of a save cannot corrupt it.
- Add an export MONTH command that writes the month as a CSV file a spreadsheet can open.
- Accept a comma as the decimal separator (12,50) as well as a dot, without breaking the checks for bad amounts.
- Once you know sys.exit (Intermediate level), end the program with exit status 1 after a loading error, so a script that runs the tracker can tell it failed.
Projects are practice: your checks run in your browser or on your computer and never count toward a certificate.
Sources
- json: JSON encoder and decoder (Python 3.14)
- Input and Output: Reading and Writing Files (The Python Tutorial, 3.14)
- Errors and Exceptions (The Python Tutorial, 3.14)
- Floating-Point Arithmetic: Issues and Limitations (The Python Tutorial, 3.14)
- Built-in Types: String Methods, str.isdigit and str.isascii (Python 3.14)
- __main__: Top-level code environment (Python 3.14)
Last reviewed September 30, 2026
Back to the Python course