All week, logan came with a plan for every day and tests written for you.
That’s how you learn a technique. It isn’t how work arrives: work arrives as a
description of what someone needs, a sample of their data, and nothing else.
This page is that, at a small scale. Different data, the same habits — and this time the tests are yours to write.
The rules
Allowed: the hints, every lesson in this course and the beginner one, the Python documentation, and an AI assistant used for explanations and hints — checked, then followed by doing it yourself.
Not allowed: pasting in a finished program, from anywhere. Nobody is watching; the point is to find out what you can do without it.
Take as many sittings as it needs.
The brief
The finance team gets expense claims as a text file, typed by hand, one claim per line: a date, an employee, a category and an amount. They want a report — and they want to know which lines were wrong, and why, so they can send them back.
An expense claims report
Requirements
- Each non-blank line is
date, employee, category, amount. Spaces around the fields don’t matter. Blank lines are skipped silently. - The date is
YYYY-MM-DD. The category istravel,mealsorequipment, in any case. The amount is positive, with at most two decimal places. - A line that breaks a rule is rejected with its line number and a reason, and the rest of the file is still counted.
- Money must add up exactly: no
0.30000000000000004anywhere. - The report shows how many claims were accepted and rejected, the totals by employee and by category (largest first), the grand total, and then each rejected line.
- Write tests: at least one for every rule in 2 and 3, checking the reason, not only that something was rejected.
Sample output for the data in the playground. Your layout and wording can differ; your numbers, and which lines are rejected, shouldn’t.
Claims: 5 accepted, 5 rejected
By employee:
Grace 374.10
Ada 55.25
Alan 18.00
By category:
equipment 310.00
travel 106.60
meals 30.75
Total 447.35
Rejected:
line 6: bad amount: '-5.00'
line 7: unknown category: 'snacks'
line 8: bad date: '2026-13-01'
line 10: expected 4 fields, got 3
line 11: bad amount: '9.999'An edge case to try. Run your report on no claims at all. It should say
0 accepted, 0 rejected and a total of zero, not crash.
import re
import sys
import unittest
from dataclasses import dataclass
from datetime import date
CLAIMS = """2026-09-01, Ada, travel, 42.50
2026-09-01, Alan, meals, 18
2026-09-02, Ada, Meals, 12.75
2026-09-02, Grace, equipment, 310.00
2026-09-03, Alan, travel, -5.00
2026-09-03, Ada, snacks, 4.20
2026-13-01, Grace, travel, 20.00
2026-09-04, Grace, travel, 64.1
2026-09-04, Alan, equipment
2026-09-05, Ada, travel, 9.999
"""
# Your design, your code, your tests.
#
# To run a TestCase in this playground:
# suite = unittest.defaultTestLoader.loadTestsFromTestCase(YourTests)
# unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)Hint 1: the idea
Every habit from this week applies. Parse once, at the edge: turn each good
line into a record with typed fields — a real date, a clean category — and
never look at the raw text again. Reject loudly and precisely: the Day 2
pattern of collecting errors, with line numbers, while the good lines carry on.
And for requirement 4, remember the beginner course’s advice about money: count
it in the smallest unit.
Hint 2: a first step
Before the report, write one function that turns a single line into a record,
or raises an error carrying a reason — and write its tests at the same time: one
good line, then one test per rule. When those pass, the reading loop and the
totals are small; the totals are Day 4’s count, adding amounts instead of
ones.
One way to do it — open after you've finished
Compare with yours after you have something working. It’s one reasonable design, not the answer — yours can differ in every line and still be good.
CATEGORIES = {"travel", "meals", "equipment"}
AMOUNT = re.compile(r"\d+(?:\.\d{1,2})?")
@dataclass(frozen=True)
class Claim:
day: date
employee: str
category: str
cents: int # money as whole cents: no float surprises
class ClaimError(ValueError):
def __init__(self, line_number, reason):
super().__init__(f"line {line_number}: {reason}")
self.line_number = line_number
self.reason = reason
def parse_amount(text):
"""'12.5' -> 1250. Positive, at most two decimal places."""
if not AMOUNT.fullmatch(text):
raise ValueError(f"bad amount: {text!r}")
whole, _, fraction = text.partition(".")
cents = int(whole) * 100 + int(fraction.ljust(2, "0") or 0)
if cents == 0:
raise ValueError(f"bad amount: {text!r}")
return cents
def parse_claim(line, line_number):
parts = [part.strip() for part in line.split(",")]
if len(parts) != 4:
raise ClaimError(line_number, f"expected 4 fields, got {len(parts)}")
day_text, employee, category, amount = parts
try:
day = date.fromisoformat(day_text)
except ValueError:
raise ClaimError(line_number, f"bad date: {day_text!r}") from None
if category.lower() not in CATEGORIES:
raise ClaimError(line_number, f"unknown category: {category!r}")
try:
cents = parse_amount(amount)
except ValueError as error:
raise ClaimError(line_number, str(error)) from None
return Claim(day, employee, category.lower(), cents)
def read_claims(lines, errors):
"""Yield the good claims; append a ClaimError to errors for each bad line."""
for number, line in enumerate(lines, start=1):
if not line.strip():
continue
try:
yield parse_claim(line, number)
except ClaimError as error:
errors.append(error)
def totals(claims, key):
"""{key(claim): total cents}, largest first."""
sums = {}
for claim in claims:
sums[key(claim)] = sums.get(key(claim), 0) + claim.cents
return dict(sorted(sums.items(), key=lambda pair: -pair[1]))
def money(cents):
return f"{cents // 100}.{cents % 100:02d}"
def report(claims, errors):
lines = [f"Claims: {len(claims)} accepted, {len(errors)} rejected"]
for heading, key in [("By employee", lambda c: c.employee),
("By category", lambda c: c.category)]:
lines.append(f"{heading}:")
for name, cents in totals(claims, key).items():
lines.append(f" {name:<12}{money(cents):>8}")
lines.append(f"{'Total':<14}{money(sum(c.cents for c in claims)):>8}")
if errors:
lines.append("Rejected:")
lines.extend(f" {error}" for error in errors)
return "\n".join(lines)
class TestClaims(unittest.TestCase):
def test_a_good_line(self):
self.assertEqual(parse_claim("2026-09-01, Ada, Travel, 42.5", 1),
Claim(date(2026, 9, 1), "Ada", "travel", 4250))
def test_amounts_become_whole_cents(self):
for text, cents in {"18": 1800, "12.75": 1275, "64.1": 6410, "0.05": 5}.items():
with self.subTest(text=text):
self.assertEqual(parse_amount(text), cents)
def test_each_kind_of_bad_line_is_rejected_with_its_reason(self):
for line, reason in {
"2026-09-04, Alan, equipment": "expected 4 fields, got 3",
"2026-13-01, Grace, travel, 20": "bad date: '2026-13-01'",
"2026-09-03, Ada, snacks, 4.20": "unknown category: 'snacks'",
"2026-09-03, Alan, travel, -5.00": "bad amount: '-5.00'",
"2026-09-05, Ada, travel, 9.999": "bad amount: '9.999'",
"2026-09-05, Ada, travel, 0": "bad amount: '0'",
}.items():
with self.subTest(line=line):
with self.assertRaises(ClaimError) as caught:
parse_claim(line, 7)
self.assertEqual(caught.exception.reason, reason)
def test_blank_lines_are_skipped_not_rejected(self):
errors = []
self.assertEqual(list(read_claims(["", " "], errors)), [])
self.assertEqual(errors, [])
def test_bad_lines_keep_their_line_numbers(self):
errors = []
list(read_claims(["2026-09-01, Ada, travel, 1", "junk"], errors))
self.assertEqual([e.line_number for e in errors], [2])
def test_an_empty_file_gives_an_empty_report(self):
self.assertEqual(report([], []).splitlines()[0], "Claims: 0 accepted, 0 rejected")
errors = []
claims = list(read_claims(CLAIMS.splitlines(), errors))
print(report(claims, errors))
suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestClaims)
unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)Some choices worth comparing with yours:
- Money is whole cents, parsed from the text with integer arithmetic, so it
never passes through a float.
money()turns it back into text only when it’s shown. (decimal.Decimalis the other good answer.) - The amount rule is a regex with
fullmatch, the Day 2 habit: describe exactly what’s allowed, and everything else is refused — including9.999, whichfloat()would have accepted without a word. totalstakes a key function, Day 4’s idea, so “by day” would be one more line inreport, not a new function.- The tests check reasons, not just failures. A test that only asked “did it raise?” would still pass if every bad line got the same useless message.
Judge your own work
Working output is where this starts. Go back to yours and answer these honestly, in writing:
- Where does text become data? Is there exactly one place where a line turns into typed values — or does raw text leak into your totals?
- How is money represented, and why? Could you explain your choice to
someone who suggested “just use
floatand round at the end”? - Could your tests fail? For each one, break the rule it checks — on purpose — and confirm the test goes red. A test that can’t fail tests nothing.
- Can you explain the design? Write a short module docstring: what the pieces are, and why they’re split where they are. If a sentence is hard to write, that seam is worth another look.
Your next project
Three briefs that use this course, for real. No solutions — you won’t need them.
Your own export. Take a CSV or log you actually have — a bank export, a
fitness app, a server you run — and build logan’s shape for it: parse at the
edge, reject with reasons, store in SQLite, and answer the questions you want
answered, with tests.
A service checker. List a few public URLs, and check them all at once with
httpx’s async client: a timeout per request, one result per URL, and a report
that says which were slow, which failed, and why. Day 6, with a real network.
A tool other people install. Package one of your tools (see Going
further in Ship It) so that
pipx install . gives you a command. Write the README first, with real output
in it, and let it tell you what’s still missing.