ይህ ትምህርት ገና ወደ አማርኛ አልተተረጎመም፤ ስለዚህ በእንግሊዝኛ ቀርቧል። የእንግሊዝኛውን ገጽ ክፈቱ
Project: Birr Ledger v1
Birr Ledger is a personal expense tracker, the project that grows with you through
the rest of the course. Version 1 remembers: you'll add expenses, list them, and see
what each category cost, all saved to ledger.json. Each milestone runs in the browser;
the last section moves it to your computer, where the file really stays.
The plan
These names are fixed for every later version:
LEDGER_FILE, the path ofledger.json, next to the script.load_expenses(path=LEDGER_FILE)returns the list of expenses, or[]if there's no file yet.save_expenses(expenses, path=LEDGER_FILE)writes the list as JSON.add_expense(expenses, amount, category, note, date=None)adds one expense; with nodate, it's dated today.total_by_category(expenses)returns each category's total.main()runs the menu, and it's the only function that prints or asks.
Each expense is a dict with four keys. Amounts are whole birr for now:
{"date": "2026-10-01", "amount": 250, "category": "food", "note": "ቡና"}It's the contact book's menu from lesson 4.9, with lessons 8.1–8.4 around it.
Milestone 1: an expense in memory
Each expense gets a date as text, like "2026-10-01". Today's date changes every day,
so a test that uses it can't say what it will print. That's why add_expense takes the
date as a parameter with a default (lesson 5.2): give a fixed date to test it, and leave
it out to mean today. None stands for "not given", and only then does the function
ask for today's date.
The parameter is called date, so inside the function it would hide date from
from datetime import date (a clash, as in lesson 8.5). Import the module instead and
write datetime.date.today().isoformat() (lesson 8.6), which gives today's date as text.
መልመጃ
add_expense
Write add_expense so it appends a new expense dict to expenses, dated date, or
today when date is None. The first test expense has a fixed date; the second is
dated today, so the test checks it against today instead of printing it.
2
{'date': '2026-10-01', 'amount': 250, 'category': 'food', 'note': 'ቡና'}
TrueYour code
import datetime
def add_expense(expenses, amount, category, note, date=None):
# If date is None, use today's date as text; then append the expense dict
pass
expenses = []
add_expense(expenses, 250, "food", "ቡና", "2026-10-01")
add_expense(expenses, 120, "transport", "ታክሲ")
print(len(expenses))
print(expenses[0])
print(expenses[1]["date"] == datetime.date.today().isoformat())In the editor, press Escape then Tab to move on.
መፍትሄውን አሳይHide the solution
This is one way to solve it, not the only one. If yours prints the same thing, it works.
import datetime
def add_expense(expenses, amount, category, note, date=None):
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
expenses = []
add_expense(expenses, 250, "food", "ቡና", "2026-10-01")
add_expense(expenses, 120, "transport", "ታክሲ")
print(len(expenses))
print(expenses[0])
print(expenses[1]["date"] == datetime.date.today().isoformat())add_expense changes the list it's given and returns nothing, like list.append; the
caller's expenses is the same list (lesson 5.3). The test lines at the bottom stand in
for main() until Milestone 3.
Milestone 2: save and load
LEDGER_FILE = Path(__file__).parent / "ledger.json" finds the file next to the script
(lesson 8.2). A missing file just means the first run: load_expenses returns [].
Both functions take the file's path as a parameter, with LEDGER_FILE as its default,
for the same reason add_expense takes date: the program leaves it out and gets
ledger.json, and a test can pass a file of its own (Module 9) without touching your
real expenses.
መልመጃ
save_expenses and load_expenses
Write both, opening path: save with ensure_ascii=False, indent=2 (lesson 8.4),
and catch FileNotFoundError when loading (lesson 8.1). There's no ledger.json
yet, so the first line is [].
[]
1
ቡናYour code
import datetime
import json
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
# Return the list in the file at path, or [] if there's no file
pass
def save_expenses(expenses, path=LEDGER_FILE):
# Write expenses to the file at path as JSON
pass
def add_expense(expenses, amount, category, note, date=None):
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
print(load_expenses())
expenses = []
add_expense(expenses, 250, "food", "ቡና", "2026-10-01")
save_expenses(expenses)
print(len(load_expenses()))
print(load_expenses()[0]["note"])In the editor, press Escape then Tab to move on.
መፍትሄውን አሳይHide the solution
This is one way to solve it, not the only one. If yours prints the same thing, it works.
import datetime
import json
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
print(load_expenses())
expenses = []
add_expense(expenses, 250, "food", "ቡና", "2026-10-01")
save_expenses(expenses)
print(len(load_expenses()))
print(load_expenses()[0]["note"])ledger.json in Files holds your expense, dated 2026-10-01, written exactly as
indent=2 lays it out. Each run here starts from an empty folder, so the first line is
always []; on your computer, the second run would start with your first expense.
ጥያቄ
Why is LEDGER_FILE built from __file__ instead of just "ledger.json"?
Why save the whole list with "w", not just the new expense with "a"? A JSON file is
one value, and two values in a row aren't JSON:
Milestone 3: the menu
Now main(): load once, then ask for commands in a loop with match (lesson 4.9).
add saves after every change, so if the program stops early (you close the window,
or press Ctrl+C), the expenses already added are in the file. A bad amount is asked
again (lesson 7.2). ledger.json starts with three expenses this time.
መልመጃ
add, list, and quit
Write main(). list prints each expense as date amount birr category note; add
asks for a whole-number amount, then the category and note, saves, and confirms.
Command: list
2026-09-28 120 birr transport ታክሲ
2026-09-29 250 birr food ምሳ
2026-09-30 60 birr food ቡና
Command: add
Amount: lots
Amount must be a whole number of birr.
Amount: 40
Category: transport
Note: ባጃጅ
Added 40 birr for transport.
Command: quit
Goodbye!Your code
import datetime
import json
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
def main():
expenses = load_expenses()
# Loop: ask for a command, then match add, list, and quit
if __name__ == "__main__":
main()In the editor, press Escape then Tab to move on.
One answer per line, given to input() in order.
ፋይሎች1 starting file
ledger.json
[
{
"date": "2026-09-28",
"amount": 120,
"category": "transport",
"note": "ታክሲ"
},
{
"date": "2026-09-29",
"amount": 250,
"category": "food",
"note": "ምሳ"
},
{
"date": "2026-09-30",
"amount": 60,
"category": "food",
"note": "ቡና"
}
]
መፍትሄውን አሳይHide the solution
This is one way to solve it, not the only one. If yours prints the same thing, it works.
import datetime
import json
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
def main():
expenses = load_expenses()
while True:
command = input("Command: ")
match command:
case "add":
amount = None
while amount is None:
try:
amount = int(input("Amount: "))
except ValueError:
print("Amount must be a whole number of birr.")
category = input("Category: ")
note = input("Note: ")
add_expense(expenses, amount, category, note)
save_expenses(expenses)
print(f"Added {amount} birr for {category}.")
case "list":
if not expenses:
print("No expenses yet.")
for e in expenses:
print(e["date"], e["amount"], "birr", e["category"], e["note"])
case "quit":
print("Goodbye!")
break
case _:
print("Unknown command. Try add, list, summary, or quit.")
if __name__ == "__main__":
main()Files shows ledger.json changed: four expenses, the new one dated today. The
while amount is None loop keeps asking because a failed int() never assigns, so
amount stays None (lesson 7.2).
Milestone 4: summary
total_by_category is the tally plan from lesson 4.4, adding amounts instead of 1,
and a Counter (lesson 8.6) can hold the totals. With a plain dict, looking up a key
that isn't there is a KeyError. Predict what a Counter does:
ውጤቱን ገምቱ
Decide before you look. Guessing wrong is how this sticks.
from collections import Counter
totals = Counter()
print(totals["food"])
totals["food"] = totals["food"] + 250
print(totals)Python prints
0
Counter({'food': 250})A Counter treats a missing key as 0, so you can add to a category's total without checking whether it's there yet.
If you picked KeyError, you expected a plain dict. A Counter counts a missing key
as 0, so totals[category] = totals[category] + amount works from the first expense
on, with no .get(). And most_common() lists the biggest total first.
መልመጃ
total_by_category and summary
This is the Milestone 3 program. Write total_by_category(expenses) with a Counter,
and add a summary case that prints each category's total, biggest first.
Command: add
Amount: 40
Category: transport
Note: ባጃጅ
Added 40 birr for transport.
Command: summary
food: 310 birr
transport: 160 birr
Command: quit
Goodbye!Your code
import datetime
import json
from collections import Counter
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
def total_by_category(expenses):
# Add each expense's amount to its category's total
pass
def main():
expenses = load_expenses()
while True:
command = input("Command: ")
match command:
case "add":
amount = None
while amount is None:
try:
amount = int(input("Amount: "))
except ValueError:
print("Amount must be a whole number of birr.")
category = input("Category: ")
note = input("Note: ")
add_expense(expenses, amount, category, note)
save_expenses(expenses)
print(f"Added {amount} birr for {category}.")
case "list":
if not expenses:
print("No expenses yet.")
for e in expenses:
print(e["date"], e["amount"], "birr", e["category"], e["note"])
# Add a "summary" case here
case "quit":
print("Goodbye!")
break
case _:
print("Unknown command. Try add, list, summary, or quit.")
if __name__ == "__main__":
main()In the editor, press Escape then Tab to move on.
One answer per line, given to input() in order.
ፋይሎች1 starting file
ledger.json
[
{
"date": "2026-09-28",
"amount": 120,
"category": "transport",
"note": "ታክሲ"
},
{
"date": "2026-09-29",
"amount": 250,
"category": "food",
"note": "ምሳ"
},
{
"date": "2026-09-30",
"amount": 60,
"category": "food",
"note": "ቡና"
}
]
መፍትሄውን አሳይHide the solution
This is one way to solve it, not the only one. If yours prints the same thing, it works.
import datetime
import json
from collections import Counter
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
def total_by_category(expenses):
totals = Counter()
for e in expenses:
totals[e["category"]] = totals[e["category"]] + e["amount"]
return totals
def main():
expenses = load_expenses()
while True:
command = input("Command: ")
match command:
case "add":
amount = None
while amount is None:
try:
amount = int(input("Amount: "))
except ValueError:
print("Amount must be a whole number of birr.")
category = input("Category: ")
note = input("Note: ")
add_expense(expenses, amount, category, note)
save_expenses(expenses)
print(f"Added {amount} birr for {category}.")
case "list":
if not expenses:
print("No expenses yet.")
for e in expenses:
print(e["date"], e["amount"], "birr", e["category"], e["note"])
case "summary":
for category, total in total_by_category(expenses).most_common():
print(f"{category}: {total} birr")
case "quit":
print("Goodbye!")
break
case _:
print("Unknown command. Try add, list, summary, or quit.")
if __name__ == "__main__":
main()Birr Ledger v1 is done. Only main() talks to the user, so Module 9 can test every
other function on its own.
Stretch: commands from the terminal
python ledger.py add 250 food ቡና is quicker than a menu (lesson 8.7). nargs="?"
makes an argument optional (None when left out), and parser.error() prints the usage
and a message and ends with exit code 2.
መልመጃ
argparse commands
This is the Milestone 4 program. Replace main() so the command comes from the
command line: add AMOUNT CATEGORY [NOTE] [--date DATE], list, or summary. The
command here is ledger.py summary; try ledger.py add 40 transport ባጃጅ, then the
same with --date 2026-10-01, then ledger.py add 40 without a category.
food: 310 birr
transport: 120 birrYour code
import argparse
import datetime
import json
from collections import Counter
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
def total_by_category(expenses):
totals = Counter()
for e in expenses:
totals[e["category"]] = totals[e["category"]] + e["amount"]
return totals
def main():
# Replace this menu with argparse: command, amount, category, note
expenses = load_expenses()
while True:
command = input("Command: ")
match command:
case "add":
amount = None
while amount is None:
try:
amount = int(input("Amount: "))
except ValueError:
print("Amount must be a whole number of birr.")
category = input("Category: ")
note = input("Note: ")
add_expense(expenses, amount, category, note)
save_expenses(expenses)
print(f"Added {amount} birr for {category}.")
case "list":
if not expenses:
print("No expenses yet.")
for e in expenses:
print(e["date"], e["amount"], "birr", e["category"], e["note"])
case "summary":
for category, total in total_by_category(expenses).most_common():
print(f"{category}: {total} birr")
case "quit":
print("Goodbye!")
break
case _:
print("Unknown command. Try add, list, summary, or quit.")
if __name__ == "__main__":
main()In the editor, press Escape then Tab to move on.
ፋይሎች1 starting file
ledger.json
[
{
"date": "2026-09-28",
"amount": 120,
"category": "transport",
"note": "ታክሲ"
},
{
"date": "2026-09-29",
"amount": 250,
"category": "food",
"note": "ምሳ"
},
{
"date": "2026-09-30",
"amount": 60,
"category": "food",
"note": "ቡና"
}
]
መፍትሄውን አሳይHide the solution
This is one way to solve it, not the only one. If yours prints the same thing, it works.
import argparse
import datetime
import json
from collections import Counter
from pathlib import Path
LEDGER_FILE = Path(__file__).parent / "ledger.json"
def load_expenses(path=LEDGER_FILE):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_expenses(expenses, path=LEDGER_FILE):
with open(path, "w", encoding="utf-8") as f:
json.dump(expenses, f, ensure_ascii=False, indent=2)
def add_expense(expenses, amount, category, note, date=None):
if date is None:
date = datetime.date.today().isoformat()
expenses.append({
"date": date,
"amount": amount,
"category": category,
"note": note,
})
def total_by_category(expenses):
totals = Counter()
for e in expenses:
totals[e["category"]] = totals[e["category"]] + e["amount"]
return totals
def main():
parser = argparse.ArgumentParser(description="Birr Ledger: track your spending.")
parser.add_argument("command", choices=["add", "list", "summary"])
parser.add_argument("amount", type=int, nargs="?")
parser.add_argument("category", nargs="?")
parser.add_argument("note", nargs="?", default="")
parser.add_argument("--date", help="YYYY-MM-DD (default: today)")
args = parser.parse_args()
expenses = load_expenses()
match args.command:
case "add":
if args.amount is None or args.category is None:
parser.error("add needs an amount and a category")
add_expense(expenses, args.amount, args.category, args.note, args.date)
save_expenses(expenses)
print(f"Added {args.amount} birr for {args.category}.")
case "list":
for e in expenses:
print(e["date"], e["amount"], "birr", e["category"], e["note"])
case "summary":
for category, total in total_by_category(expenses).most_common():
print(f"{category}: {total} birr")
if __name__ == "__main__":
main()With ledger.py add 40 transport ባጃጅ it prints Added 40 birr for transport. and
Files shows the new expense, dated today. --date is an option: a name starting
with -- that can be left out, and its value is None when it is. Add
--date 2026-10-01 and the new expense is dated 2026-10-01. With ledger.py add 40,
parser.error() prints the
usage line and ledger.py: error: add needs an amount and a category, and the program
ends with exit code 2.
Build it for real
On a computer, ledger.json stays, and Birr Ledger becomes a tool you can use every
day. The course's exercises repo has a starter with these exact names in
08-birr-ledger.
Open it in VS Code, or in a free
GitHub Codespace
(lesson 8.8). Build it milestone by milestone, run python ledger.py in that folder
(python3 on macOS and Linux, lesson 0.4), add
a few real expenses, and run it again tomorrow: they'll still be there.
ዋና ዋና ነጥቦች
- The names are fixed:
LEDGER_FILE,load_expenses,save_expenses,add_expense,total_by_category,main. - Load once, save the whole list with
"w"after every change; a missing file means an empty ledger. - Only
main()prints and asks; the other functions take values and return them. LEDGER_FILEis built from__file__, so the ledger is found from any folder.