Learning Goals
3 minBy the end of this lesson you can:
- Turn a list of messy order lines into a clean, aligned receipt.
- Skip a bad line and report it, instead of crashing on it.
- Combine split, strip, title, validation and f-string widths in one program.
Warm-Up · What Could Go Wrong?
5 minHere is the data the program will receive. Before writing anything, read it and list what is wrong with each line.
orders.txt, as typed
pizza, 2, 3.50 HOT CHOCOLATE ,3, 2.50 pancake,1,2.00 noodles ice cream, two, 4.90 hot dog,10,1.20
Answer
- Line 1 is fine, but has spaces after the commas.
- Line 2 is shouting, and padded with spaces at both ends.
- Line 3 is perfect.
- Line 4 has one field instead of three — splitting it will not give three values, and unpacking will raise
ValueError. - Line 5 has a quantity of
"two", whichint()cannot convert. - Line 6 has trailing spaces after the price.
Four different problems in six lines. That is normal.
Two of these lines cannot become a receipt row. Your choices are: crash, silently drop them, or report them and carry on. Only the third is any use to the person at the stall.
New Concept · The Shape of a Text Pipeline
12 minEvery program in this section has had the same three stages. Naming them makes the project straightforward rather than daunting.
The pipeline
PARSE CHECK FORMAT split the line is it usable? build the row strip each part skip if not align the columns fix the case convert numbers add up the total
- Parse — lesson 3 (
split) and lesson 2 (strip,title). - Check — lesson 5 (
len()of the split,isdigit()). - Format — lesson 4 (f-string widths and
:.2f).
Keeping the stages separate is what makes the program readable. Parse everything first, then check, then format — rather than doing all three inside one tangled loop.
# One line, through all three stages raw = " HOT CHOCOLATE ,3, 2.50" # PARSE fields = raw.split(",") name = fields[0].strip().title() qty_text = fields[1].strip() price_text = fields[2].strip() # CHECK if qty_text.isdigit(): qty = int(qty_text) price = float(price_text) # FORMAT print(f"{name:<18}{qty:>4}{price * qty:>9.2f}")
Output
Hot chocolate 3 7.50
float() has no isdigit() guard here"3.50".isdigit() is False — the dot is not a digit — so isdigit() is the wrong test for a price. Checking a decimal properly needs either replace(".", "", 1).isdigit() or the try/except from Section 7. The build below uses the replace trick, and you should know it is a workaround, not the real answer.
Build It · receipt.py, Four Steps
12 minType it in four goes, running it after each one. Do not paste the whole thing.
Step 1 — the data and the header
# receipt.py - messy order lines in, a tidy receipt out STALL = "Food stall Pak Din" orders = [ "pizza, 2, 3.50", " HOT CHOCOLATE ,3, 2.50", "pancake,1,2.00", "noodles", "ice cream, two, 4.90", "hot dog,10,1.20 ", ] header = f"{'Item':<18}{'Qty':>4}{'Unit':>9}{'Total':>10}" print(STALL.center(len(header))) print("=" * len(header)) print(header) print("-" * len(header))
center() is new — it pads both sides so the stall name sits in the middle of the receipt. Run it: you should see a heading and two rules, no rows yet.
Step 2 — parse and check each line
rejected = [] grand_total = 0 for raw in orders: fields = raw.split(",") if len(fields) != 3: rejected.append(raw.strip()) continue name = fields[0].strip().title() qty_text = fields[1].strip() price_text = fields[2].strip() if not qty_text.isdigit(): rejected.append(raw.strip()) continue if not price_text.replace(".", "", 1).isdigit(): rejected.append(raw.strip()) continue
continue abandons this line and moves to the next. Each check runs before the conversion it protects — that is the whole trick.
Step 3 — format the row
qty = int(qty_text) unit = float(price_text) line_total = qty * unit grand_total = grand_total + line_total print(f"{name:<18}{qty:>4}{unit:>9.2f}{line_total:>10.2f}")
Step 4 — the total and the rejects
print("-" * len(header)) print(f"{'GRAND TOTAL':<18}{'':>4}{'':>9}{grand_total:>10.2f}") print("=" * len(header)) if rejected: print() print(f"{len(rejected)} line(s) could not be read:") for bad in rejected: print(f" {bad!r}") print() print("Thank you!".center(len(header)))
Output
Food stall Pak Din
=========================================
Item Qty Unit Total
-----------------------------------------
Pizza 2 3.50 7.00
Hot chocolate 3 2.50 7.50
Pancake 1 2.00 2.00
Hot dog 10 1.20 12.00
-----------------------------------------
GRAND TOTAL 28.50
=========================================
2 line(s) could not be read:
'noodles'
'ice cream, two, 4.90'
Thank you!Four good rows, two reported, one correct total, and nothing crashed. The person at the stall can see exactly which two lines to retype.
Try It Yourself
13 minAt the moment a rejected line just appears in a list. Change it so each one carries a reason — wrong number of fields or quantity is not a number — and print the reason beside the line.
Hint
rejected.append([raw.strip(), "wrong number of fields"]) # later for bad, reason in rejected: print(f" {bad!r} - {reason}")
Two additions to the receipt:
- A 6% service charge line between the rule and the grand total, with the total including it.
- Add
"layer cake with extra gula Rome, 2, 1.80"to the orders. Its name is longer than the 18-character column — decide whether to widen the column or slice the name to fit, and write one comment saying which you chose and why.
Mini-Challenge 🔥 · Make It Yours
8 minTurn the receipt into a bill for something you actually know: a canteen order, a cafe supper, a school book list, or a bubble-tea run for your class.
It works if:
- At least six order lines, of which at least two are deliberately broken in different ways.
- The columns stay aligned when you change the widths in the header alone.
- The grand total is right, to two decimal places.
- Broken lines are reported, never silently dropped, and nothing crashes.
If you finish early: add a quantity discount — buy 10 or more of anything and that line gets 10% off, shown as its own column.
Every printed line must use an f-string. If you find yourself counting spaces inside a string literal to line something up, use a width instead.
Recap · The Whole Section
3 minSix lessons, and this project used all of them. Strings are rows of characters you can index and slice; methods hand back tidy copies rather than changing the original; split and join move between text and lists; f-strings build output that lines up; and asking a string what it contains before converting it is what stops programs crashing on real typing.
Vocabulary Card
- pipeline
- Parse, then check, then format — kept as separate stages.
continue- Abandons the current loop pass and starts the next one.
- center
- Pads a string on both sides to a given width.
- graceful failure
- Reporting a bad input and carrying on, rather than stopping.
Where next. Section 7 · Errors & Debugging replaces the isdigit guards with try / except, which handles decimals properly and reads better. Section 2 · Lists goes deeper into the list that split hands you.
Homework
4 minFinish your own version of the receipt and bring the file plus a screenshot of its output.
- Your own stall or shop name, centred.
- Six or more order lines, at least two broken in different ways.
- Aligned columns, a correct grand total, and a rejected-lines report with reasons.
- One extra feature of your choosing — service charge, discount, item count, or the cheapest and dearest line.
Sample · receipt.py
# receipt.py - a canteen bill with reasons, service charge and a count STALL = "Hillside School Jalil Canteen" orders = [ "pizza, 2, 3.50", " HOT CHOCOLATE ,3, 2.50", "pancake,1,2.00", "noodles", "ice cream, two, 4.90", "hot dog,10,1.20 ", "iced cocoa, 4, 2.20", ] SERVICE_RATE = 0.06 header = f"{'Item':<18}{'Qty':>4}{'Unit':>9}{'Total':>10}" print(STALL.center(len(header))) print("=" * len(header)) print(header) print("-" * len(header)) rejected = [] subtotal = 0 item_count = 0 for raw in orders: fields = raw.split(",") if len(fields) != 3: rejected.append([raw.strip(), "wrong number of fields"]) continue name = fields[0].strip().title() qty_text = fields[1].strip() price_text = fields[2].strip() if not qty_text.isdigit(): rejected.append([raw.strip(), "quantity is not a whole number"]) continue if not price_text.replace(".", "", 1).isdigit(): rejected.append([raw.strip(), "price is not a number"]) continue qty = int(qty_text) unit = float(price_text) line_total = qty * unit subtotal = subtotal + line_total item_count = item_count + qty print(f"{name:<18}{qty:>4}{unit:>9.2f}{line_total:>10.2f}") service = subtotal * SERVICE_RATE grand_total = subtotal + service print("-" * len(header)) print(f"{'Subtotal':<18}{'':>4}{'':>9}{subtotal:>10.2f}") print(f"{'Service 6%':<18}{'':>4}{'':>9}{service:>10.2f}") print(f"{'GRAND TOTAL':<18}{'':>4}{'':>9}{grand_total:>10.2f}") print("=" * len(header)) print(f"{item_count} items on this bill.") if rejected: print() print(f"{len(rejected)} line(s) could not be read:") for bad, reason in rejected: print(f" {bad!r} - {reason}") print() print("Thank you!".center(len(header)))
Output
Hillside School Jalil Canteen
=========================================
Item Qty Unit Total
-----------------------------------------
Pizza 2 3.50 7.00
Hot chocolate 3 2.50 7.50
Pancake 1 2.00 2.00
Hot dog 10 1.20 12.00
Iced Cocoa 4 2.20 8.80
-----------------------------------------
Subtotal 37.30
Service 6% 2.24
GRAND TOTAL 39.54
=========================================
20 items on this bill.
2 line(s) could not be read:
'noodles' - wrong number of fields
'ice cream, two, 4.90' - quantity is not a whole number
Thank you!Your stall, items and extra feature will differ. What should match: every check runs before the conversion it protects, rejected lines carry a reason, and every printed line is an f-string with widths rather than hand-counted spaces.