Lesson 6 · Python Beginner Course
Python Regex & f-strings for Beginners: Clean Messy Text (Beginner Python Course, Part 6)
In this lesson you'll be able to: Line up tidy tables, prices, percentages and dates with f-string format specs Wrap, trim and clean text with textwrap and…
- CoursePython Beginner Course
- Lesson6 of 10
- Video58 min
- FormatJupyter notebook · 93 code cells
What you'll learn
Data
No separate download needed — the notebook creates or downloads everything it uses.
📓 Full notebook
Download .ipynbText Wrangling: f-strings, Regular Expressions and Unicode#
In this lesson you'll be able to:
- Line up tidy tables, prices, percentages and dates with f-string format specs
- Wrap, trim and clean text with
textwrapand thestringmodule - Find, extract, replace and split text with regular expressions (
re) - Validate emails, UK mobile numbers and postcodes before they reach your data
- Handle accents, bytes and encodings without your text turning into gibberish
- Build a capstone: a messy customer notes cleaner that prints a tidy report
Warm-Up#
Real-world text is messy: extra spaces, random capitals, stray punctuation. Let's poke at some with the string methods you already know.
note = " Table 4: two FLAT whites, one croissant "
print(repr(note))
print(repr(note.strip()))
print(note.strip().lower().replace("flat whites", "lattes"))
words = note.split()
print(len(words), "words:", words)
print("Shouty words:", [w for w in words if w.isupper()])
print("croissant" in note, note.count("o"))
print(note.strip().title())
print(note.strip().startswith("Table"), note.find("croissant"))
f-string Format Specs: Width and Alignment#
A format spec is a seating plan: you tell each value how many seats it gets and where to sit.
Everything after the colon in {value:spec} is the spec. A number sets the width, then <, > or ^ choose left, right or centre.
drink = "Latte"
price = 3.5
print(f"{drink} costs {price}")
print(f"{drink} costs ${price:.2f}")
print(f"[{drink:10}]")
print(f"[{price:10}]")
print(f"[{drink:<10}] left")
print(f"[{drink:>10}] right")
print(f"[{drink:^10}] centre")
print(f"[{drink:*^10}] centre, padded with *")
menu = {"Espresso": 2.2, "Flat white": 3.4, "Hot chocolate": 3.95, "Tea": 1.8}
print(f"{'Item':<15}{'Price':>8}")
print("-" * 23)
for item, cost in menu.items():
print(f"{item:<15}{cost:>8.2f}")
width = 30
print(f"{' Cafe Menu ':=^{width}}")
for item, cost in menu.items():
print(f"{item:.<{width - 6}}${cost:>5.2f}")
print("=" * width)
steps = 8432
goal = 10000
print(f"{steps=}")
print(f"{goal - steps=}")
print(f"{steps / goal=:.1%}")
name = " Zoe "
print(f"Hello {name}!")
print(f"Hello {name!r}!")
print(f"Hello {name.strip()!r}!")
playlist = [("Morning Run", 215), ("Slow Jam", 368), ("Cooldown", 74)]
for number, (song, seconds) in enumerate(playlist, start=1):
mins, secs = divmod(seconds, 60)
print(f"{number:>2}. {song:<12}{mins:>2}:{secs:02d}")
total_secs = sum(s for _, s in playlist)
print(f" {'Total':<12}{total_secs // 60:>2}:{total_secs % 60:02d}")
Numbers: Decimals, Thousands and Percentages#
Same number, different outfits: the value never changes, only how it's dressed for display.
.2f two decimals, , thousands separators, % percentage, + always show the sign, 05d pad with zeros.
total = 1234567.891
print(f"{total}")
print(f"{total:.2f}")
print(f"{total:,.2f}")
print(f"{total:_.0f}")
print(f"{total:.3e}")
rate = 0.0768
print(f"{rate}")
print(f"{rate:%}")
print(f"{rate:.1%}")
print(f"{0.5:.0%} off all pastries after 4pm")
for change in [12.5, -3.4, 0]:
print(f"Weight change: {change:+.1f} kg")
for order_no in [7, 42, 315]:
print(f"Order #{order_no:05d}")
budget = {"Rent": 950, "Food": 310.5, "Travel": 86.2, "Fun": 120}
total = sum(budget.values())
for item, cost in budget.items():
print(f"{item:<8}${cost:>8,.2f} {cost / total:>6.1%}")
print(f"{'Total':<8}${total:>8,.2f} {1:>6.0%}")
Common mistake: a number spec on a string#
Text that looks like a number is still text. .2f only works on real numbers.
price_text = "3.50"
print(f"${price_text:.2f}")
price_text = "3.50"
print(f"${float(price_text):.2f}")
print(f"${float(price_text) * 3:.2f} for three")
Quick check#
What does f"{0.256:.0%}" print? And f"{7:03d}"?
print(f"{0.256:.0%}")
print(f"{7:03d}")
Dates in f-strings#
Dates have their own spec codes: %d day, %m month, %Y year, %A weekday name, %H:%M time.
We'll use fixed example dates so your output matches mine.
from datetime import datetime, date
booking = datetime(2026, 3, 14, 19, 30)
print(f"{booking}")
print(f"{booking:%d/%m/%Y}")
print(f"{booking:%A %d %B %Y at %H:%M}")
print(f"{booking:%I:%M %p}")
print(f"{booking:%a %d %b}")
print(f"Day {booking:%j} of the year")
classes = [date(2026, 3, 16), date(2026, 3, 18), date(2026, 3, 21)]
for day in classes:
kind = "weekend" if day.weekday() >= 5 else "weekday"
print(f"{day:%a %d %b} Spin class ({kind})")
Mini-Project: The Cafe Receipt Printer#
Width, alignment, decimals, percentages and dates, all on one receipt.
order = [("Flat white", 2, 3.40), ("Croissant", 1, 2.75), ("Banana bread", 3, 2.95)]
for item, qty, unit in order:
print(f"{qty}x {item:<14}@ ${unit:.2f} = ${qty * unit:>5.2f}")
def receipt(order, when, discount=0.0, width=32):
lines = [f"{' THE DAILY GRIND ':*^{width}}", f"{when:%d %b %Y %H:%M}".center(width)]
subtotal = 0
for item, qty, unit in order:
subtotal += qty * unit
lines.append(f"{qty}x {item:<{width - 10}}${qty * unit:>6.2f}")
lines.append("-" * width)
if discount:
lines.append(f"{'Discount ' + format(discount, '.0%'):<{width - 8}}-${subtotal * discount:>6.2f}")
lines.append(f"{'TOTAL':<{width - 7}}${subtotal * (1 - discount):>6.2f}")
return lines
print(len(receipt(order, booking)), "lines ready")
print("\n".join(receipt(order, booking)))
print("\n".join(receipt(order, datetime(2026, 3, 15, 8, 5), discount=0.1)))
The string Module and textwrap#
Ready-made alphabets and a polite word-wrapper, both free with Python.
import string
print(string.ascii_lowercase)
print(string.ascii_uppercase)
print(string.digits)
print(string.punctuation)
print(len(string.ascii_letters), "letters,", len(string.punctuation), "punctuation marks")
review = "Great coffee!!! Friendly staff, but... the Wi-Fi? Terrible :("
no_punct = "".join(ch for ch in review if ch not in string.punctuation)
print(no_punct)
print(string.capwords("the daily grind cafe"))
code = "AB12-cd34"
allowed = string.ascii_letters + string.digits + "-"
print(all(ch in allowed for ch in code))
print(sum(ch.isdigit() for ch in code), "digits")
import textwrap
blurb = ("Our beans are roasted every Tuesday in small batches, then rested for "
"three days so the flavours settle before they reach your cup.")
print(textwrap.fill(blurb, width=40))
print()
print(textwrap.fill(blurb, width=60))
for line in textwrap.wrap(blurb, width=30):
print(f"| {line:<30} |")
print(textwrap.shorten(blurb, width=50, placeholder="..."))
print(textwrap.indent(textwrap.fill(blurb, 36), "> "))
message = """
Hi Sam,
Your order is ready for collection.
Thanks!
"""
print(textwrap.dedent(message).strip())
pasted = "\u201cBest\u201d caf\u00e9 in town\u2026 isn\u2019t it?"
fixes = str.maketrans({"\u201c": '"', "\u201d": '"', "\u2019": "'", "\u2026": "..."})
print(pasted)
print(pasted.translate(fixes))
Your turn#
- Print the cafe menu inside a box with
|borders, exactly 28 characters wide. - Use
textwrap.shortento make a 25-character preview of thereviewtext.
Regular Expressions: Finding Patterns#
A regex is a search by description: "any five digits" instead of "12345".
Pro tip: always write patterns as raw strings,
r"...", so backslashes reachreuntouched.
import re
text = "Order 4471 shipped. Order 4472 is delayed."
match = re.search(r"\d+", text)
print(match)
print(match.group(), match.start(), match.end())
print(re.findall(r"\d+", text))
print(re.findall(r"Order \d+", text))
print(re.findall(r"\d+", "No numbers here"))
sample = "Room 7B!"
shortcuts = {r"\d": "a digit", r"\w": "letter/digit/_", r"\s": "whitespace", r".": "any character"}
for pat, meaning in shortcuts.items():
print(f"{pat:<4}{meaning:<16}{re.findall(pat, sample)}")
flight = "Gate 7, flight 123, seat 4567"
for pat in [r"\d", r"\d+", r"\d{3}", r"\d{2,4}"]:
print(f"{pat:<8}", re.findall(pat, flight))
print(re.findall(r"colou?r", "colour or color? Colour!"))
print(re.findall(r"[aeiou]", "espresso"))
print(re.findall(r"[A-Z]\w+", "Meet Ada and Ben at Bristol Temple Meads"))
print(re.findall(r"\bcat\b", "cat catalogue bobcat cat."))
print(re.findall(r"[^0-9 ]+", "Bus 42 to 7 Dials"))
print(re.search(r"^Hello", "Hello there") is not None, re.search(r"^Hello", "Oh, Hello") is not None)
print(re.match(r"\d+", "42 lattes"))
print(re.match(r"\d+", "lattes 42"))
print(re.fullmatch(r"\d+", "42"))
print(re.fullmatch(r"\d+", "42a"))
Regex cheat sheet:
\ddigit,\wletter, digit or underscore,\swhitespace,.any character+one or more,*zero or more,?optional,{3}exactly three,{2,4}two to four[abc]any one of a, b or c,[A-Z]a capital letter,[^0-9]anything except a digit^start of the text,$end,\bthe edge of a word
order_text = "1x Latte (large) $3.50, 2x Latte (small) $2.90"
print(re.findall(r"Latte \((\w+)\)", order_text))
literal = re.escape("(large) $3.50")
print(literal)
print(re.search(literal, order_text) is not None)
search looks anywhere, match only at the start, fullmatch must cover the whole string.
Common mistake: calling .group() when nothing matched#
m = re.search(r"\d{5}", "No order number here")
print(m.group())
m = re.search(r"\d{5}", "No order number here")
if m:
print("Found", m.group())
else:
print("No match, so search returned", m)
Groups and Named Groups#
Brackets in a pattern are labelled boxes: you get the whole match, plus the pieces you care about.
line = "Flight BA2490 departs 14:35 from gate 22"
m = re.search(r"(\d{2}):(\d{2})", line)
print(m.group(0), m.group(1), m.group(2))
print(m.groups())
hours = int(m.group(1))
minutes = int(m.group(2))
print(f"{hours * 60 + minutes} minutes after midnight")
schedule = "Yoga 07:30, Spin 12:15, Boxing 18:45"
pairs = re.findall(r"(\w+) (\d{2}:\d{2})", schedule)
print(pairs)
for gym_class, time in pairs:
print(f"{gym_class:<8}{time}")
flight_re = r"(?P<airline>[A-Z]{2})(?P<number>\d{3,4})"
m = re.search(flight_re, line)
print(m.group("airline"), m.group("number"))
print(m.groupdict())
tickets = "BA2490 to Rome, EZ812 to Nice, FR33 to Oslo"
for m in re.finditer(flight_re, tickets):
print(f"{m['airline']} flight {m['number']} found at position {m.start()}")
dates = "Booked 14/03/2026, paid 02/04/2026"
for day, month, year in re.findall(r"(\d{2})/(\d{2})/(\d{4})", dates):
print(f"{year}-{month}-{day}")
for booking_ref in ["Table for 4 at 19:30", "Table for 2", "table for 12 at 20:00"]:
m = re.search(r"[Tt]able for (\d+)(?: at (\d{2}:\d{2}))?", booking_ref)
people, time = m.groups()
print(f"{people:>2} people, time: {time or 'not given'}")
Quick check#
When a pattern has two groups, what does re.findall give back?
print(re.findall(r"(\w+)@(\w+)", "ann@cafe and bo@gym"))
Common mistake: an unclosed bracket in the pattern#
To match a real bracket character, escape it: \(.
re.search(r"(\d+", "Table 12")
print(re.search(r"(\d+)", "Table 12").group(1))
print(re.search(r"\(\d+\)", "Table (12)").group())
sub, split, Flags and compile#
searchfinds,subreplaces,splitchops. Flags tweak the rules;compilesaves a pattern for reuse.
messy = "Too many spaces here"
print(re.sub(r"\s+", " ", messy))
print(re.sub(r"\d", "#", "Card 4929 1234 5678 9012"))
card = "Card 4929 1234 5678 9012"
print(re.sub(r"\d{4} \d{4} \d{4} (\d{4})", r"**** **** **** \1", card))
print(re.sub(r"(?P<d>\d{2})/(?P<m>\d{2})/(?P<y>\d{4})", r"\g<y>-\g<m>-\g<d>", dates))
def tidy_price(m):
return f"${float(m.group(1)):.2f}"
print(re.sub(r"\$([\d.]+)", tidy_price, "Tea $2, cake $3.5, lunch $12.25"))
print(re.split(r"[,;|]\s*", "milk, eggs;bread|butter, jam"))
print(re.split(r",\s*|\s+and\s+", "tea, coffee and cake"))
print(re.split(r"\s*\|\s*", "2026-03-02 | Ada |note"))
shout = "WOW. Wow! wow?"
print(re.findall(r"wow", shout))
print(re.findall(r"wow", shout, flags=re.IGNORECASE))
log = "ok: fine\nERROR: disk full\nok: fine\nERROR: out of milk"
print(re.findall(r"^ERROR: (.+)$", log, flags=re.MULTILINE))
time_re = re.compile(r"""
(?P<hour>[01]\d|2[0-3]) # 00 to 23
:
(?P<minute>[0-5]\d) # 00 to 59
""", re.VERBOSE)
for t in ["09:30", "23:59", "24:00", "7:5"]:
print(f"{t:<6}", "valid" if time_re.fullmatch(t) else "invalid")
opening = "Open 08:00 to 17:30, late night Friday 21:00"
print(time_re.findall(opening))
print(time_re.sub("--:--", opening))
Your turn#
- Use
re.subto turn every run of dots or exclamation marks into a single one. - Compile a pattern that finds prices like
$4.50and use it withfindallon a menu string.
Validating Emails, Phone Numbers and Postcodes#
Validation is a bouncer at the door: check the shape of the data before you let it in.
(?:...) groups without capturing, handy when you need brackets but not the piece.
EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
for e in ["ada@example.com", "ben.smith+gym@mail.co.uk", "no-at-sign.com", "eve@site", "a b@x.com"]:
print(f"{e:<26}{'ok' if EMAIL.fullmatch(e) else 'rejected'}")
print(EMAIL.search("Reply to ada@example.com today").group())
PHONE = re.compile(r"(?:\+44\s?|0)7\d{3}\s?\d{6}")
for p in ["07700 900123", "07700900123", "+44 7700 900123", "0161 496 0000", "07700 9001"]:
print(f"{p:<18}{'ok' if PHONE.fullmatch(p) else 'rejected'}")
found = PHONE.findall("Call 07700 900111 or 07700900222 after 6pm")
print("Inside a sentence:", found)
def tidy_phone(raw):
digits = re.sub(r"\D", "", raw)
if digits.startswith("44"):
digits = "0" + digits[2:]
return f"{digits[:5]} {digits[5:]}"
for p in ["+44 7700 900123", "07700-900-123", "(07700) 900 123"]:
print(f"{p:<18} -> {tidy_phone(p)}")
POSTCODE = re.compile(r"[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}", re.IGNORECASE)
for pc in ["SW1A 1AA", "M1 1AE", "b33 8th", "LS1 4AP", "12345", "EC1A1BB"]:
print(f"{pc:<10}{'ok' if POSTCODE.fullmatch(pc) else 'rejected'}")
def tidy_postcode(raw):
squashed = re.sub(r"\s", "", raw).upper()
return f"{squashed[:-3]} {squashed[-3:]}"
print([tidy_postcode(pc) for pc in ["b33 8th", "EC1A1BB", " m1 1ae "]])
Pro tip: a regex checks the shape, not that the address is real. The only true email test is sending one.
Mini-Project: The Gym Sign-Up Form#
Keep asking until each answer has the right shape, then print a member card.
CHECKS = {"email": EMAIL, "phone": PHONE, "postcode": POSTCODE}
def ask_valid(kind):
while True:
answer = input(f"Your {kind}: ").strip()
if CHECKS[kind].fullmatch(answer):
return answer
print(f" That doesn't look like a valid {kind}, please try again.")
print("Checks ready for:", ", ".join(CHECKS))
profile = {} # ask_valid asks for input()
for kind in CHECKS:
profile[kind] = ask_valid(kind)
print(profile)
profile["phone"] = tidy_phone(profile["phone"])
profile["postcode"] = tidy_postcode(profile["postcode"])
print(f"{' NEW MEMBER ':=^34}")
for kind, value in profile.items():
print(f"{kind.title():<10}{value:>24}")
print("=" * 34)
Unicode: Characters, Bytes and Encodings#
Text is characters for humans; files and networks store bytes. An encoding is the dictionary between them.
UTF-8 is the encoding to use almost everywhere. Always pass encoding="utf-8" when you read or write text files.
word = "caf\u00e9"
data = word.encode("utf-8")
print(len(word), "characters")
print(data, len(data), "bytes")
for ch in "a\u00e9\u20ac":
print(ch, ord(ch), ch.encode("utf-8"))
print(chr(233), chr(8364))
print("\N{LATIN SMALL LETTER E WITH ACUTE}" == "\u00e9", "\u00e9".encode("latin-1"), "\u00e9".encode("utf-8"))
print(data.decode("utf-8"))
print(data.decode("latin-1"), " <- wrong dictionary, scrambled text")
Common mistake: decoding with the wrong encoding#
ASCII only knows 128 characters, so it has no idea what an accented e is.
data.decode("ascii")
print(data.decode("utf-8"))
print(data.decode("ascii", errors="replace"))
from pathlib import Path
folder = Path("lesson_files")
folder.mkdir(exist_ok=True)
menu_file = folder / "menu.txt"
menu_file.write_text("Cr\u00e8me br\u00fbl\u00e9e $5\nJalape\u00f1o toastie $6\n", encoding="utf-8")
text = menu_file.read_text(encoding="utf-8")
print(text, menu_file.stat().st_size, "bytes for", len(text), "characters")
print("Stra\u00dfe".lower(), "|", "Stra\u00dfe".casefold())
print("STRASSE".lower() == "Stra\u00dfe".lower())
print("STRASSE".casefold() == "Stra\u00dfe".casefold())
import unicodedata
a = "caf\u00e9"
b = "cafe\u0301"
print(a, b, a == b, len(a), len(b))
for ch in b[-2:]:
print(repr(ch), unicodedata.name(ch))
nfc = unicodedata.normalize("NFC", b)
print(nfc == a, len(nfc))
nfd = unicodedata.normalize("NFD", a)
print(nfd == b, len(nfd))
def strip_accents(text):
pieces = unicodedata.normalize("NFD", text)
return "".join(ch for ch in pieces if not unicodedata.combining(ch))
for person in ["Zo\u00eb", "Jos\u00e9", "Ren\u00e9e", "\u0141ukasz"]:
print(f"{person:<8} -> {strip_accents(person)}")
def same_name(x, y):
return strip_accents(x).casefold() == strip_accents(y).casefold()
members = ["Jos\u00e9", "Zo\u00eb Nakamura", "Ren\u00e9e", "Ben"]
query = input("Search members: ")
print([m for m in members if same_name(query, m.split()[0])])
for ch in "$5 \u00d1!":
print(f"{ch!r:<5}{unicodedata.category(ch):<4}{unicodedata.name(ch)}")
Your turn#
- Write
same_text(a, b)that ignores accents, capitals and extra spaces, then test it on" REN\u00c9E "and"renee". - Save
"Zo\u00eb"to a file with UTF-8, then read it back withencoding="latin-1"and look at the result.
Quick check#
Why are len("caf\u00e9") and len("caf\u00e9".encode("utf-8")) different?
print(len("caf\u00e9"), "characters,", len("caf\u00e9".encode("utf-8")), "bytes: \u00e9 needs two bytes in UTF-8")
Capstone: The Messy Customer Notes Cleaner#
A cafe's staff type customer notes in a hurry. Our job: pull out emails, phone numbers and order IDs, tidy names and text, flag urgent notes and print a report someone can actually read.
raw_notes = """2026-03-02 | ada LOVELACE | Rang re order #A1042, email ADA.L@Example.com, mobile 07700 900123. Wants refund!!!
2026-03-03 | jos\u00e9 garc\u00eda | order a1043 & A1044 not arrived... contact jose.garcia@mail.co.uk / +44 7700 900456
2026-03-03 | Ben Okafor | Cake was squashed :( call (07700) 900-789 , postcode m1 1ae. Order A1045
2026-03-04 | ZO\u00cb NAKAMURA | email zoe@cafe-fans.org ,no phone given. order #A1046 lovely!!
2026-03-05 | sam lee | email sam@@typo.com ?? phone 0770 090 no order number"""
notes_file = folder / "customer_notes.txt"
notes_file.write_text(raw_notes, encoding="utf-8")
print(f"Saved {len(raw_notes.splitlines())} notes ({notes_file.stat().st_size:,} bytes)")
rows = []
for line in notes_file.read_text(encoding="utf-8").splitlines():
when, who, note = re.split(r"\s*\|\s*", line)
rows.append({"date": date.fromisoformat(when), "name": who, "note": note})
print(rows[1])
FIND_EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
FIND_PHONE = re.compile(r"(?:\+44\s?|\(?0)7\d{3}\)?[\s-]?\d{3}[\s-]?\d{3}")
FIND_ORDER = re.compile(r"\bA\d{4}\b", re.IGNORECASE)
FIND_POSTCODE = re.compile(r"\b[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}\b", re.IGNORECASE)
URGENT = re.compile(r"refund|not arrived|squashed|complain", re.IGNORECASE)
print(FIND_PHONE.findall(raw_notes))
def extract(note):
return {
"emails": [e.lower() for e in FIND_EMAIL.findall(note)],
"phones": [tidy_phone(p) for p in FIND_PHONE.findall(note)],
"orders": [o.upper() for o in FIND_ORDER.findall(note)],
"postcodes": [tidy_postcode(p) for p in FIND_POSTCODE.findall(note)],
"urgent": bool(URGENT.search(note)),
}
print(extract(rows[2]["note"]))
def clean_name(name):
return re.sub(r"\s+", " ", name).strip().title()
for row in rows:
print(f"{row['name']!r:<18} -> {clean_name(row['name'])}")
def clean_note(note):
note = re.sub(r":\(|:\)", "", note)
note = re.sub(r"([!?.])\1+", r"\1", note)
note = re.sub(r"\s+([,.!?])", r"\1", note)
note = re.sub(r",(?=\S)", ", ", note)
return re.sub(r"\s+", " ", note).strip()
print(clean_note(rows[2]["note"]))
print(clean_note(rows[3]["note"]))
records = []
for row in rows:
record = {"date": row["date"], "name": clean_name(row["name"]), "note": clean_note(row["note"])}
record.update(extract(row["note"]))
records.append(record)
print(len(records), "records, keys:", list(records[0]))
def report_card(r, width=60):
flag = " [URGENT]" if r["urgent"] else ""
print(f"{r['date']:%a %d %b} {r['name']}{flag}")
print(f" Email : {', '.join(r['emails']) or '-'}")
print(f" Phone : {', '.join(r['phones']) or '-'}")
print(f" Orders: {', '.join(r['orders']) or '-'}")
if r["postcodes"]:
print(f" Post : {', '.join(r['postcodes'])}")
print(textwrap.indent(textwrap.fill(r["note"], width - 3), " > "))
report_card(records[0])
print(f"{' CUSTOMER NOTES REPORT ':=^60}")
for r in records[1:]:
report_card(r)
print("-" * 60)
Finally, an at-a-glance table, a few headline numbers and a saved report file.
def yes_no(items):
return "yes" if items else "no"
print(f"{'Customer':<16}{'Orders':>7}{'Email':>7}{'Phone':>7} Postcode")
for r in records:
print(f"{r['name']:<16}{len(r['orders']):>7}{yes_no(r['emails']):>7}{yes_no(r['phones']):>7} "
f"{', '.join(r['postcodes']) or '-'}")
total = len(records)
counts = {
"with an email": sum(1 for r in records if r["emails"]),
"with a phone": sum(1 for r in records if r["phones"]),
"flagged urgent": sum(r["urgent"] for r in records),
}
for label, n in counts.items():
print(f"Notes {label:<15}{n:>2} of {total} ({n / total:.0%})")
all_orders = sorted({o for r in records for o in r["orders"]})
print(f"{len(all_orders)} order IDs:", ", ".join(all_orders))
follow_up = [r["name"] for r in records if not (r["emails"] or r["phones"])]
print("Need contact details:", follow_up)
lines = [f"{r['date']:%Y-%m-%d} | {r['name']:<14} | {', '.join(r['orders']) or '-':<12} | "
f"{', '.join(r['emails'] + r['phones']) or 'NO CONTACT'}" for r in records]
report_file = folder / "notes_report.txt"
report_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(report_file.read_text(encoding="utf-8"))
def urgent_then_date(r):
return (not r["urgent"], r["date"])
for r in sorted(records, key=urgent_then_date):
label = "URGENT" if r["urgent"] else "ok"
print(f"{label:<7}{r['date']:%d %b} {r['name']:<14}{textwrap.shorten(r['note'], 32, placeholder='...')}")
key = strip_accents("Zoe Nakamura").casefold()
match = [r for r in records if strip_accents(r["name"]).casefold() == key]
print(f"Searched without accents, found: {match[0]['name']} ({match[0]['emails'][0]})")
Recap#
- f-string specs: width,
<>^alignment, fill characters,.2f,,thousands,%,+,05d {value=}for quick debugging and!rto see hidden spaces- Dates inside f-strings with
%d %b %Y,%A,%H:%M stringconstants andtextwrap:fill,wrap,shorten,indent,dedentre:search,match,fullmatch,findall,finditer,sub,split- Groups, named groups
(?P<name>...), flags likeIGNORECASE,MULTILINE,VERBOSE, andcompile - Validating emails, UK mobiles and postcodes by shape, then tidying them into one format
- Unicode: characters vs bytes,
encode/decodewith UTF-8,casefold,unicodedata.normalize
Pro tip: when a regex grows past one line, switch to
re.VERBOSEand comment each piece.
Try it yourself#
- Teach the cleaner to spot UK landlines (like
0161 496 0000) and list them separately from mobiles. - Add a mood score: count happy words (lovely, great) and unhappy words (refund, squashed) per note.
- Save the records as a CSV with
csv.DictWriter, then open it again and print only the urgent rows.
What's next#
Lesson 7: Dates and Times: Calendars, Time Zones and Reminders. You'll calculate ages, countdowns and business days, handle time zones, and build a birthday and bill reminder planner.
Found this useful?
All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.



