Zum Inhalt springen
aviral gupta

// Projekt Einstieg · etwa 5 Stunden Arbeit

Ausgaben-Tracker

Sie bauen einen kleinen Befehlsinterpreter, der Ihre Ausgaben in expenses.json speichert. Er liest Befehle wie add 2026-09-12 food 12.50 lunch, list 2026-09 und summary 2026-09 von der Eingabe und gibt eine Zeile je Ausgabe oder eine Summe je Kategorie aus. Beträge werden als ganze Cent in einem int gespeichert, weil ein float 0.10 nicht exakt darstellen kann und Summen sonst abweichen. Fehlt die Datei, beginnen Sie leer; ist sie beschädigt, bricht das Programm mit einer klaren Meldung ab und lässt sie unangetastet. Das Projekt nutzt die ganze Stufe Einstieg: Funktionen, Listen, Dicts, f-Strings, Dateien, JSON und Ausnahmen.

Was das fertige Programm kann

  • Befehle kommen zeilenweise über die Standardeingabe: add DATUM KATEGORIE BETRAG [NOTIZ], list [MONAT], summary [MONAT], help und quit. Leere Zeilen werden übersprungen.
  • Beträge werden mit ASCII-Ziffern als 12, 12.5 oder 12.50 geschrieben und als ganze Cent (int) gespeichert. Negative Werte, null, Text, andere Ziffern wie 0 und mehr als zwei Nachkommastellen werden abgelehnt.
  • Datumsangaben haben die Form JJJJ-MM-TT und müssen existieren (kein 2026-02-31; der 29. Februar nur in Schaltjahren), Monate die Form JJJJ-MM. Kategorien werden kleingeschrieben gespeichert; die Notiz ist alles nach dem Betrag.
  • Jedes add wird sofort in expenses.json gespeichert, als eingerückte JSON-Liste von Objekten mit date, category, cents und note.
  • list gibt eine Zeile je Ausgabe aus, die älteste zuerst: Datum, Kategorie auf 12 Zeichen aufgefüllt, Betrag rechtsbündig in 10 Zeichen, dann die Notiz.
  • summary gibt eine Zeile je Kategorie in alphabetischer Reihenfolge aus, dann eine Gesamtzeile; ohne passende Ausgaben geben beide Befehle No expenses. aus.
  • Ein falscher Befehl oder Wert ergibt eine Zeile, die mit Error: beginnt (oder einen Hinweis auf help), und ändert nichts.
  • Fehlt expenses.json, beginnt eine leere Liste. Ist die Datei kein gültiges JSON oder keine Liste von Ausgaben, nennt eine Fehlermeldung die Datei, und das Programm endet, ohne sie zu schreiben.

Aufbau der Startdateien

main.py
Das ganze Programm: Beträge lesen und formatieren, die JSON-Datei laden und speichern, Filter und Summen, die Befehlsverarbeitung und main(). Die Prüfung von Datum und Monat (mit der Schaltjahrregel) und die Strukturprüfung _looks_like_expense sind schon geschrieben; der Rest sind mit TODO markierte Platzhalter.

Etappen

  1. Etappe 1

    Geld als ganze Cent

    Schreiben Sie parse_amount und format_cents. Teilen Sie den Text mit str.partition am Punkt, prüfen Sie die Ziffern mit isascii und isdigit (isdigit allein akzeptiert Ziffern voller Breite) und rechnen Sie die Cent nur mit int aus, nie mit float.

    Prüfungen, die nach dieser Etappe bestehen:

    • parse_amount macht aus "12.50", "12.5" und "7" ganze Cent
    • parse_amount löst bei abc, -3, 1.234, 0 und leerem Text ValueError aus
    • parse_amount löst bei Ziffern voller Breite wie 0.50 und 12 ValueError aus
    • format_cents zeigt 1250 als 12.50 und 5 als 0.05
  2. Etappe 2

    Die Datei sicher laden

    Schreiben Sie load_expenses und save_expenses mit with open(...). Fangen Sie FileNotFoundError ab, um leer zu beginnen, und machen Sie aus json.JSONDecodeError oder Daten, die keine Liste von Einträgen sind, die die vorgegebene _looks_like_expense besteht, einen ValueError, der die Datei nennt.

    Prüfungen, die nach dieser Etappe bestehen:

    • load_expenses gibt [] zurück, wenn die Datei fehlt
    • load_expenses löst bei ungültigem JSON einen ValueError aus, der die Datei nennt
    • load_expenses löst einen ValueError aus, wenn das JSON keine Liste von Ausgaben ist
  3. Etappe 3

    Filtern und summieren

    Schreiben Sie in_month, das einen Monat behält und nach Datum sortiert, und totals_by_category, das Cent je Kategorie in einem Dict addiert und nach Kategorie sortiert zurückgibt.

    Prüfungen, die nach dieser Etappe bestehen:

    • totals_by_category summiert Cent je Kategorie, alphabetisch geordnet
    • in_month behält die Ausgaben eines Monats, die älteste zuerst
  4. Etappe 4

    Der Befehl add

    Zerlegen Sie in handle die Zeile in Wörter, bauen Sie mit make_expense eine Ausgabe und hängen Sie sie an. Fangen Sie ValueError ab und geben Sie eine Zeile mit Error: zurück, damit ein Tippfehler nie abstürzt. Das vorgegebene check_date lehnt den 31. Februar schon ab.

    Prüfungen, die nach dieser Etappe bestehen:

    • add bestätigt die Eingabe und speichert den Betrag als Cent in expenses.json
    • Ein falscher Betrag, ein falsches Datum oder ein unbekannter Befehl ergibt eine Meldung, gespeichert wird nichts
    • add lehnt 2026-02-31 und 2026-02-29 ab, nimmt aber den Schalttag 2024-02-29 an
  5. Etappe 5

    Berichte und eine beschädigte Datei

    Schreiben Sie list_lines und summary_lines mit Feldbreiten im f-String, binden Sie list und summary in handle ein und lassen Sie main den Ladefehler ausgeben und enden, bevor es Befehle liest.

    Prüfungen, die nach dieser Etappe bestehen:

    • list 2026-09 zeigt die Ausgaben des Monats, die älteste zuerst, und die Daten bleiben zwischen Läufen erhalten
    • summary 2026-09 zeigt eine Summe je Kategorie und eine Gesamtzeile
    • Bei beschädigter expenses.json meldet das Programm einen Fehler und lässt die Datei unverändert

Im Browser bauen

Bearbeiten Sie unten main.py; die übrigen Startdateien bleiben, wie sie sind. „Code prüfen“ führt alle Abnahmetests aus. Bis zur letzten Etappe schlagen also einige fehl.

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser Python herunter (bis zu 6.5 MB) und speichert es im Cache. Ihr Code bleibt auf Ihrem Gerät.

Auf dem eigenen Rechner bauen

Legen Sie einen Ordner mit diesen Startdateien an, installieren Sie Python 3.14 und arbeiten Sie die Etappen ab. Die Abnahmetests starten Sie jederzeit mit:

Starter als eine .zip-Datei herunterladen (Starter-Dateien, test_main.py und learnrun.py)
python learnrun.py test

Unter macOS und Linux tippen Sie python3, wo in diesen Befehlen python steht, wie in der ersten Lektion.

learnrun.py herunterladen

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."""
    # 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()

Abnahmetests

Das Projekt ist fertig, wenn jede Prüfung in test_main.py besteht. Lesen Sie sie vor dem Start: Sie sind die Spezifikation, als Code geschrieben.

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 macht aus "12.50", "12.5" und "7" ganze Cent"""
    got = parse_amount("12.50"), parse_amount("12.5"), parse_amount("7")
    assert got == (1250, 1250, 700), f"parse_amount ergab {got!r}, erwartet: (1250, 1250, 700)"


def test_parse_amount_rejects():
    """parse_amount löst bei abc, -3, 1.234, 0 und leerem Text ValueError aus"""
    for text in ["abc", "-3", "1.234", "0", ""]:
        try:
            got = parse_amount(text)
        except ValueError:
            continue
        raise AssertionError(f"parse_amount({text!r}) gab {got!r} zurück, erwartet: ein ValueError")


def test_parse_amount_rejects_wide_digits():
    """parse_amount löst bei Ziffern voller Breite wie 0.50 und 12 ValueError aus"""
    for text in ["0.50", "12"]:
        try:
            got = parse_amount(text)
        except ValueError:
            continue
        raise AssertionError(f"parse_amount({text!r}) gab {got!r} zurück, erwartet war ein ValueError (nur die ASCII-Ziffern 0-9 bilden Beträge)")


def test_format_cents():
    """format_cents zeigt 1250 als 12.50 und 5 als 0.05"""
    got = format_cents(1250), format_cents(5), format_cents(80000)
    assert got == ("12.50", "0.05", "800.00"), f"format_cents ergab {got!r}, erwartet: ('12.50', '0.05', '800.00')"


def test_missing_file_is_empty():
    """load_expenses gibt [] zurück, wenn die Datei fehlt"""
    with tempfile.TemporaryDirectory() as folder:
        got = load_expenses(os.path.join(folder, "nothing-here.json"))
    assert got == [], f"bei fehlender Datei gab load_expenses {got!r} zurück, erwartet: []"


def test_corrupt_file_raises():
    """load_expenses löst bei ungültigem JSON einen ValueError aus, der die Datei nennt"""
    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"die Fehlermeldung {str(err)!r} sollte die Datei broken.json nennen"
            return
    raise AssertionError(f"bei beschädigter Datei gab load_expenses {got!r} zurück, erwartet: ein ValueError")


def test_wrong_shape_raises():
    """load_expenses löst einen ValueError aus, wenn das JSON keine Liste von Ausgaben ist"""
    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"bei {{'food': 12.5}} gab load_expenses {got!r} zurück, erwartet: ein ValueError")


def test_totals_by_category():
    """totals_by_category summiert Cent je Kategorie, alphabetisch geordnet"""
    got = totals_by_category(SAMPLE)
    want = {"food": 4355, "rent": 80000, "transport": 490}
    assert got == want, f"totals_by_category ergab {got!r}, erwartet: {want!r}"
    assert list(got) == ["food", "rent", "transport"], f"die Kategorien kamen in der Reihenfolge {list(got)!r}, erwartet: alphabetisch"


def test_in_month():
    """in_month behält die Ausgaben eines Monats, die älteste zuerst"""
    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') ergab die Daten {got!r}"
    got_all = len(in_month(SAMPLE, None))
    assert got_all == 4, f"in_month(SAMPLE, None) behielt {got_all} Ausgaben, erwartet: alle 4"


def test_add_saves_cents():
    """add bestätigt die Eingabe und speichert den Betrag als Cent 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 gab {out.strip()!r} aus, erwartet: '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 enthält {saved!r}, erwartet: {want!r}"


def test_bad_input_is_reported():
    """Ein falscher Betrag, ein falsches Datum oder ein unbekannter Befehl ergibt eine Meldung, gespeichert wird nichts"""
    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"erwartet: eine Zeile je fehlerhaftem Befehl, erhalten: {out!r}"
    assert out[0].startswith("Error:") and out[1].startswith("Error:"), f"die fehlerhaften add-Befehle gaben {out[:2]!r} aus, erwartet: Zeilen, die mit 'Error:' beginnen"
    assert "help" in out[2], f"der unbekannte Befehl gab {out[2]!r} aus, erwartet: ein Hinweis auf help"
    assert not os.path.exists(DATA), "expenses.json wurde geschrieben, obwohl nichts hinzukam"


def test_impossible_dates():
    """add lehnt 2026-02-31 und 2026-02-29 ab, nimmt aber den Schalttag 2024-02-29 an"""
    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"die unmöglichen Daten ergaben {out[:2]!r}, erwartet waren Zeilen, die mit 'Error:' beginnen"
    assert out[2] == "Added 1.00 food on 2024-02-29.", f"der Schalttag ergab {out[2]!r}, erwartet war '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 enthält die Daten {saved!r}, erwartet war nur ['2024-02-29']"


def test_list_month():
    """list 2026-09 zeigt die Ausgaben des Monats, die älteste zuerst, und die Daten bleiben zwischen Läufen erhalten"""
    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 gab {out!r} aus, erwartet: {want!r}"


def test_summary():
    """summary 2026-09 zeigt eine Summe je Kategorie und eine Gesamtzeile"""
    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 gab {out!r} aus, erwartet: {want!r}"
    out = run_main("summary 2025-01\n").splitlines()
    assert out == ["No expenses."], f"summary für einen leeren Monat gab {out!r} aus, erwartet: ['No expenses.']"


def test_corrupt_file_kept():
    """Bei beschädigter expenses.json meldet das Programm einen Fehler und lässt die Datei unverändert"""
    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"das Programm gab {out.strip()!r} aus, erwartet: ein Fehler, der {DATA} nennt"
    with open(DATA, encoding="utf-8") as f:
        kept = f.read()
    fresh()
    assert kept == broken, f"expenses.json enthält jetzt {kept!r}, erwartet: unverändert"

Das fertige Programm starten

python main.py
Referenzlösung

Versuchen Sie zuerst die Etappen. Diese Lösung besteht alle Abnahmetests und die Typprüfung.

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()

Weiterentwickeln

  • Ergänzen Sie einen Befehl delete N, der die N-te Ausgabe der letzten Liste entfernt, und überlegen Sie, was N bedeuten soll, wenn die Liste nach Monat gefiltert war.
  • Führen Sie ein Budget je Kategorie in einer zweiten JSON-Datei ein und lassen Sie summary zeigen, wie viel davon noch übrig ist.
  • Schreiben Sie die Daten zuerst in eine temporäre Datei und ersetzen Sie expenses.json dann mit os.replace, damit ein Absturz beim Speichern die Datei nicht beschädigen kann.
  • Ergänzen Sie einen Befehl export MONAT, der den Monat als CSV-Datei für eine Tabellenkalkulation schreibt.
  • Akzeptieren Sie neben dem Punkt auch das Komma als Dezimaltrennzeichen (12,50), ohne die Prüfung auf falsche Beträge zu schwächen.
  • Sobald Sie sys.exit kennen (Stufe Aufbau), beenden Sie das Programm nach einem Ladefehler mit dem Exit-Status 1, damit ein Skript, das den Tracker aufruft, den Fehler erkennt.

Projekte sind Übung: Ihre Prüfungen laufen im Browser oder auf Ihrem Rechner und zählen nie für eine Bescheinigung.