Lesson 4 · Python Beginner Course
Python Masterclass: Data Structures, OOP and Automation (Beginner Python Course, Part 4)
In this lesson you'll be able to: Pick the right container (list, tuple, dict or set) and use it like a pro Write Pythonic one-liners with comprehensions,…
- CoursePython Beginner Course
- Lesson4 of 10
- Video1 h 1 min
- FormatJupyter notebook · 89 code cells
What you'll learn
- Lists in Depth: Python's Everyday Workhorse
- The Copy Trap and 2D Lists
- Tuples and Unpacking: Fixed Records, Elegant Code
- Comprehensions: Build Lists in One Line
- Dictionaries in Depth: Look Anything Up Instantly
- Sets: Uniqueness and Lightning-Fast Membership
- Functions Level-Up: args, kwargs, Scope and Lambda
- Error Handling Done Right
Data
No separate download needed — the notebook creates or downloads everything it uses.
📓 Full notebook
Download .ipynbPython Masterclass: Data Structures, OOP and Automation#
In this lesson you'll be able to:
- Pick the right container (list, tuple, dict or set) and use it like a pro
- Write Pythonic one-liners with comprehensions, unpacking and lambdas
- Write flexible functions and handle errors the right way
- Model your own things with classes, inheritance and dataclasses
- Split code into modules and automate files with pathlib, csv, json and datetime
- Build a project: an automated weekly Excel sales report for an online plant shop
Lists in Depth: Python's Everyday Workhorse#
A list is an ordered, changeable sequence: perfect when order matters and items come and go.
basket = ["monstera", "pothos", "aloe"]
basket.append("peace lily")
basket.extend(["zz plant", "pothos"])
basket.insert(0, "fiddle leaf fig")
print(basket)
print(len(basket), "items,", basket.count("pothos"), "of them pothos")
basket.remove("pothos") # removes the FIRST match only
last = basket.pop() # takes the last item off and hands it back
print("Popped:", last)
print("aloe is at index", basket.index("aloe"))
print(basket)
print(basket[:2]) # first two
print(basket[-2:]) # last two
print(basket[::-1]) # reversed copy
prices = [24.0, 9.5, 42.0, 12.75, 18.0]
cheapest_first = sorted(prices)
print(cheapest_first)
print(prices) # the original is untouched
prices.sort(reverse=True)
print(prices) # .sort() changed the list itself
Pro tip: sorted() returns a new list; .sort() changes the list and returns None. So never write prices = prices.sort()
plants = ["monstera", "zz plant", "aloe", "fiddle leaf fig", "pothos"]
print(sorted(plants, key=len))
staff = ["oscar", "Amara", "bea", "Zoe"]
print(sorted(staff))
print(sorted(staff, key=str.lower))
monday_stock = ["monstera", "pothos", "aloe"]
tuesday_stock = monday_stock
tuesday_stock.append("calathea")
print("Monday: ", monday_stock)
print("Same object?", tuesday_stock is monday_stock)
monday_stock = ["monstera", "pothos", "aloe"]
tuesday_stock = monday_stock.copy()
tuesday_stock.append("calathea")
print("Monday: ", monday_stock)
print("Tuesday:", tuesday_stock)
shelves = [
["monstera", "pothos", "aloe"],
["calathea", "zz plant", "fern"],
["cactus", "ivy", "orchid"],
]
print(shelves[1])
print(shelves[1][2])
for number, shelf in enumerate(shelves, start=1):
print(f"Shelf {number}: {' | '.join(shelf)}")
print("Bottom-right corner:", shelves[-1][-1])
import copy
plan_b = shelves.copy()
plan_c = copy.deepcopy(shelves)
shelves[0][0] = "bonsai"
print("plan_b:", plan_b[0][0], "| plan_c:", plan_c[0][0])
Pro tip: .copy() is enough for a flat list. For lists inside lists, use copy.deepcopy(). Your turn: build a 2 x 3 grid of your own (desk drawers? bookshelf?), print it shelf by shelf, then print the bottom-right item with negative indexes.
Tuples and Unpacking: Fixed Records, Elegant Code#
A tuple is a sealed parcel: once it's packed, the contents can't change.
box_size = (40, 30, 25) # length, width, height in cm
print(box_size[0], "cm long")
print(type(box_size), len(box_size))
Common mistake: trying to change a tuple#
box_size[0] = 45
box_size = (45, 30, 25) # build a new tuple instead
length, width, height = box_size
litres = length * width * height / 1000
print(f"{length} x {width} x {height} cm holds {litres} litres")
opening, closing = "Priya", "Tomasz"
opening, closing = closing, opening
print("Opening:", opening, "| Closing:", closing)
daily_orders = [14, 9, 11, 17, 8, 21, 19]
monday, *midweek, sunday = daily_orders
print(monday, midweek, sunday)
def price_range(prices):
return min(prices), max(prices)
low, high = price_range([24.0, 9.5, 42.0, 12.75])
print(f"Prices run from £{low:.2f} to £{high:.2f}")
plant_names = ["monstera", "pothos", "aloe"]
in_stock = [7, 0, 12]
for plant, count in zip(plant_names, in_stock):
status = "SOLD OUT" if count == 0 else f"{count} left"
print(f"{plant:<10} {status}")
Comprehensions: Build Lists in One Line#
Shape: [what_you_want for item in collection if condition]
prices = [32.0, 12.5, 9.0, 45.0, 18.0]
with_vat = []
for price in prices:
with_vat.append(round(price * 1.2, 2))
print(with_vat)
with_vat = [round(price * 1.2, 2) for price in prices]
print(with_vat)
bargains = [price for price in prices if price < 20]
tiers = ["premium" if price >= 30 else "standard" for price in prices]
print(bargains)
print(tiers)
print("Anything over £40?", any(price > 40 for price in prices))
print("All under £50? ", all(price < 50 for price in prices))
print("Total with VAT: ", round(sum(price * 1.2 for price in prices), 2))
every_plant = [plant for shelf in shelves for plant in shelf]
print(every_plant)
empty_grid = [[0] * 3 for _ in range(2)]
print(empty_grid)
Pro tip: if a comprehension needs two conditions or wraps onto a second line, a normal for loop is kinder to whoever reads it next.
Dictionaries in Depth: Look Anything Up Instantly#
A dictionary is a cloakroom: hand over the ticket (the key) and get the coat (the value) back.
catalogue = {
"MON-01": {"name": "Monstera", "price": 32.0, "stock": 7},
"POT-02": {"name": "Golden Pothos", "price": 12.5, "stock": 0},
"ALO-03": {"name": "Aloe Vera", "price": 9.0, "stock": 12},
}
print(catalogue["MON-01"])
print(catalogue["MON-01"]["price"])
Common mistake: asking for a key that isn't there#
catalogue["FIG-04"]
print(catalogue.get("FIG-04"))
print(catalogue.get("FIG-04", "not in the catalogue"))
print("FIG-04" in catalogue)
catalogue["FIG-04"] = {"name": "Fiddle Leaf Fig", "price": 45.0, "stock": 3}
catalogue["POT-02"]["stock"] += 20
for code, item in catalogue.items():
value = item["price"] * item["stock"]
print(f"{code} {item['name']:<16} {item['stock']:>3} in stock £{value:>7.2f}")
sales = [("amara", "MON-01"), ("bea", "ALO-03"), ("amara", "POT-02"),
("oscar", "ALO-03"), ("bea", "FIG-04"), ("amara", "ALO-03")]
by_customer = {}
for customer, code in sales:
by_customer.setdefault(customer, []).append(code)
print(by_customer)
units_sold = {}
for _, code in sales:
units_sold[code] = units_sold.get(code, 0) + 1
print(units_sold)
price_list = {code: item["price"] for code, item in catalogue.items()}
low_stock = {item["name"]: item["stock"] for item in catalogue.values()
if item["stock"] < 10}
print(price_list)
print(low_stock)
house_style = {"font": "Georgia", "colour": "green", "logo": True}
spring_promo = {"colour": "pink", "banner": "Spring sale!"}
print(house_style | spring_promo)
from collections import Counter
print(Counter(code for _, code in sales).most_common(2))
Pro tip: use d[key] when a missing key is a bug, and d.get(key, default) when it's normal.
Sets: Uniqueness and Lightning-Fast Membership#
A set is a guest list: each name appears once, and checking a name is instant.
tags = ["indoor", "low light", "pet safe", "indoor", "low light", "trailing"]
unique_tags = set(tags)
print(len(tags), "tags,", len(unique_tags), "unique")
print(sorted(unique_tags))
week_1 = {"amara", "bea", "oscar", "kofi", "lena"}
week_2 = {"bea", "kofi", "mateo", "lena", "yusuf"}
print("Came back: ", sorted(week_1 & week_2))
print("All customers: ", sorted(week_1 | week_2))
print("Did not return:", sorted(week_1 - week_2))
print("New in week 2: ", sorted(week_2 - week_1))
delivery_zones = {"N1", "N7", "E2", "E8", "SE1"}
for postcode in ["E8", "W4", "SE1"]:
verdict = "we deliver" if postcode in delivery_zones else "collection only"
print(postcode, "->", verdict)
emails = ["Bea@Mail.com", "kofi@mail.com", "bea@mail.com", "LENA@mail.com"]
unique_emails = {email.lower() for email in emails}
print(sorted(unique_emails))
vip = {"bea@mail.com", "lena@mail.com"}
print("All VIPs ordered?", vip <= unique_emails)
Quick check: you have 10,000 order emails with lots of repeats. What's the shortest way to count unique customers?
Functions Level-Up: *args, **kwargs, Scope and Lambda#
def order_total(*prices, discount=0):
return round(sum(prices) * (1 - discount), 2)
print(order_total(32.0, 12.5))
print(order_total(9.0, 9.0, 45.0, discount=0.1))
print(order_total())
def care_label(name, **care):
details = ", ".join(f"{key}: {value}" for key, value in care.items())
return f"{name.upper()} ({details})"
print(care_label("Aloe Vera", light="bright", water="every 3 weeks"))
care_tips = {"light": "low", "water": "monthly", "pet_safe": "no"}
print(care_label("Snake Plant", **care_tips))
Scope follows LEGB: Python looks for a name in the Local function, then any Enclosing function, then Global (the file), then Built-ins.
shop_name = "Fernway" # global
def banner():
greeting = "Welcome to" # local
return f"{greeting} {shop_name}!"
print(banner())
print(len(banner()), "characters") # len is a built-in
def make_discounter(percent):
def discounted(price):
return round(price * (1 - percent / 100), 2) # percent is enclosing
return discounted
staff_price = make_discounter(20)
print(staff_price(32.0), staff_price(12.5))
Common mistake: changing a global inside a function#
orders_today = 0
def record_order():
orders_today += 1
record_order()
def record_order(count):
return count + 1
orders_today = record_order(orders_today)
orders_today = record_order(orders_today)
print("Orders today:", orders_today)
Pro tip: pass values in and return results out. The global keyword works, but reach for it rarely, if ever.
products = [("Monstera", 32.0, 7), ("Pothos", 12.5, 20),
("Aloe", 9.0, 12), ("Fig", 45.0, 3)]
by_price = sorted(products, key=lambda p: p[1])
print([name for name, _, _ in by_price])
print("Most stock value:", max(products, key=lambda p: p[1] * p[2]))
print("Lowest stock:", min(products, key=lambda p: p[2])[0])
team = [("Priya", "packing", 31), ("Tomasz", "delivery", 27),
("Ines", "packing", 31), ("Kofi", "delivery", 35)]
rota = sorted(team, key=lambda person: (person[1], -person[2]))
for name, role, parcels in rota:
print(f"{role:<9} {name:<7} {parcels} parcels")
Your turn:
- Write average_price(*prices) that returns 0 when no prices are given.
- Sort the products list by stock, highest first, with sorted, a lambda and reverse=True.
pots = int("twelve")
while True:
answer = input("How many pots would you like? ")
try:
pots = int(answer)
except ValueError:
print(f"'{answer}' isn't a whole number, please use digits.")
else:
print(f"Great, {pots} pots added to your basket.")
break
def stock_level(levels, code):
try:
return levels[code]
except KeyError:
print(f"Unknown product {code}")
return 0
finally:
print(f"(lookup for {code} logged)")
levels = {"MON-01": 7, "ALO-03": 12}
print(stock_level(levels, "MON-01"))
print(stock_level(levels, "FIG-04"))
def apply_discount(price, percent):
if not 0 <= percent <= 50:
raise ValueError(f"discount must be 0-50%, got {percent}%")
return round(price * (1 - percent / 100), 2)
print(apply_discount(40, 15))
try:
apply_discount(40, 80)
except ValueError as err:
print("Rejected:", err)
class OutOfStockError(Exception):
"""Raised when an order asks for more than we have."""
def reserve(stock, code, quantity):
available = stock.get(code, 0)
if quantity > available:
raise OutOfStockError(f"{code}: wanted {quantity}, only {available} left")
stock[code] = available - quantity
return stock[code]
stock = {"MON-01": 7, "FIG-04": 3}
for code, qty in [("MON-01", 2), ("FIG-04", 5), ("MON-01", 4)]:
try:
left = reserve(stock, code, qty)
except OutOfStockError as err:
print("Could not reserve ->", err)
else:
print(f"Reserved {qty} x {code}, {left} left")
Pro tip: catch the specific exception you expect. A bare except: also hides typos and bugs. Quick check: if the try block succeeds, which blocks run: else, finally, or both?
Classes and Objects: Model Your Own Things#
A class is a blank product listing template; each object is one filled-in listing.
class Plant:
category = "houseplant" # class attribute: shared by all plants
def __init__(self, name, price, stock=0):
self.name = name # instance attributes: one set per plant
self.price = price
self.stock = stock
def restock(self, amount):
self.stock += amount
return self.stock
monstera = Plant("Monstera", 32.0, 7)
aloe = Plant("Aloe Vera", 9.0)
print(monstera.name, monstera.stock, "|", aloe.name, aloe.stock)
print(aloe.restock(10), "aloes now")
print(monstera.category, aloe.category)
print(monstera)
Common mistake: forgetting a required argument#
fern = Plant("Boston Fern")
class Plant:
category = "houseplant"
def __init__(self, name, price, stock=0):
self.name, self.price, self.stock = name, price, stock
def stock_value(self):
return self.price * self.stock
def __str__(self):
return f"{self.name} - £{self.price:.2f} ({self.stock} in stock)"
def __repr__(self):
return f"Plant({self.name!r}, {self.price}, {self.stock})"
fern = Plant("Boston Fern", 14.0, 5)
print(fern)
print([fern, Plant("Ivy", 8.5, 9)])
print(f"Stock value: £{fern.stock_value():.2f}")
Plant.category = "indoor plant"
print(fern.category)
fern.category = "fern" # creates an instance attribute on fern only
print(fern.category, "|", Plant("Ivy", 8.5).category)
Pro tip: always write a repr. When you debug a list of objects, readable reprs save you minutes every time.
Inheritance, super() and Dataclasses#
class SeasonalPlant(Plant):
def __init__(self, name, price, stock=0, season="spring"):
super().__init__(name, price, stock)
self.season = season
def __str__(self):
return f"{super().__str__()} [best in {self.season}]"
tulips = SeasonalPlant("Tulip Bulbs", 6.0, 40, season="autumn")
print(tulips)
print("Inherited method:", tulips.stock_value())
print(isinstance(tulips, Plant), isinstance(fern, SeasonalPlant))
inventory = [fern, tulips, Plant("Calathea", 22.0, 4)]
for item in inventory:
print(item)
total = sum(item.stock_value() for item in inventory)
print(f"Total stock value: £{total:.2f}")
from dataclasses import dataclass, field
@dataclass
class OrderLine:
plant: str
quantity: int
unit_price: float
@property
def total(self):
return self.quantity * self.unit_price
line = OrderLine("Monstera", 2, 32.0)
print(line)
print(line.total)
print(line == OrderLine("Monstera", 2, 32.0))
@dataclass
class Order:
customer: str
lines: list[OrderLine] = field(default_factory=list)
def add(self, plant, quantity, unit_price):
self.lines.append(OrderLine(plant, quantity, unit_price))
def receipt(self):
print(f"Fernway receipt for {self.customer}")
for item in self.lines:
print(f" {item.quantity} x {item.plant:<14} £{item.total:>7.2f}")
print(f" {'TOTAL':<18} £{sum(i.total for i in self.lines):>7.2f}")
order = Order("Amara")
order.add("Monstera", 2, 32.0)
order.add("Aloe Vera", 3, 9.0)
order.add("Plant Food", 1, 6.5)
order.receipt()
Pro tip: if a class is mostly data, start with @dataclass. Never write lines: list = [] as a default; use field(default_factory=list).
Modules and Packages: Split Code into Files#
- A module is any .py file. A package is a folder of modules with an init.py file.
%%writefile fernway_tools.py
"""Small helpers for the Fernway plant shop."""
VAT_RATE = 0.20
def add_vat(price):
return round(price * (1 + VAT_RATE), 2)
def format_gbp(amount):
return f"£{amount:,.2f}"
import fernway_tools
print(fernway_tools.add_vat(32.0))
from fernway_tools import format_gbp, VAT_RATE
print(format_gbp(1234.5), "| VAT rate:", VAT_RATE)
from pathlib import Path
package = Path("fernway")
package.mkdir(exist_ok=True)
(package / "__init__.py").write_text('"""Fernway shop package."""\n')
(package / "shipping.py").write_text(
"def shipping_cost(weight_kg):\n"
" extra = max(0, weight_kg - 2) * 1.5\n"
" return round(4.99 + extra, 2)\n"
)
print(sorted(p.name for p in package.iterdir()))
from fernway.shipping import shipping_cost
print("1.5 kg:", shipping_cost(1.5), "| 5 kg:", shipping_cost(5))
Pro tip: Python ships with a huge standard library. Before you install anything, check whether it's already built in. Your turn: add a function free_shipping(total) to fernway_tools.py that returns True for orders of £50 or more, then import and test it.
The Automation Toolbox: random, datetime, csv, json and pathlib#
import random
random.seed(7)
print(random.choice(["Monstera", "Pothos", "Aloe Vera"]))
print(random.randint(1, 4), round(random.uniform(5, 50), 2))
from datetime import date, datetime, timedelta
week_start = date(2026, 3, 2)
print(week_start.strftime("%A %d %B %Y"))
print("Week ends:", week_start + timedelta(days=6))
print((date(2026, 3, 20) - week_start).days, "days until the spring sale")
print(datetime(2026, 3, 2, 9, 30).strftime("Opened at %H:%M"))
CATALOGUE = {
"Monstera": 32.0, "Golden Pothos": 12.5, "Aloe Vera": 9.0,
"Fiddle Leaf Fig": 45.0, "Calathea": 22.0, "Snake Plant": 18.0,
}
CUSTOMERS = ["Amara", "Bea", "Oscar", "Kofi", "Lena", "Mateo", "Yusuf", "Ines"]
random.seed(42)
orders = []
for order_id in range(1001, 1041):
plant = random.choice(list(CATALOGUE))
day = week_start + timedelta(days=random.randint(0, 6))
orders.append({
"order_id": order_id, "date": day.isoformat(),
"customer": random.choice(CUSTOMERS), "plant": plant,
"quantity": random.randint(1, 4), "unit_price": CATALOGUE[plant],
})
orders.sort(key=lambda o: (o["date"], o["order_id"]))
print(len(orders), "orders. The first one:")
print(orders[0])
import csv
csv_path = Path("orders_2026-03-02.csv")
with csv_path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=orders[0].keys())
writer.writeheader()
writer.writerows(orders)
print("Saved", csv_path.name, "-", csv_path.stat().st_size, "bytes")
with csv_path.open(newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
print(rows[0])
print(type(rows[0]["quantity"]))
Common mistake: saving a date as JSON#
import json
summary = {"week_start": week_start, "orders": len(orders)}
json.dumps(summary)
summary = {"week_start": week_start.isoformat(), "orders": len(orders),
"customers": sorted({o["customer"] for o in orders})}
json_path = Path("week_summary.json")
json_path.write_text(json.dumps(summary, indent=2))
loaded = json.loads(json_path.read_text())
print(loaded["orders"], "orders from", len(loaded["customers"]), "customers")
for pattern in ["*.csv", "*.json", "*.py"]:
for path in Path(".").glob(pattern):
print(f"{path.name:<24} {path.suffix:<6} {path.stat().st_size:>6} bytes")
import shutil
stamp = datetime(2026, 3, 9, 8, 0).strftime("%Y%m%d-%H%M")
backup = Path("backup") / stamp
backup.mkdir(parents=True, exist_ok=True)
for path in Path(".").glob("*.csv"):
shutil.copy2(path, backup / path.name)
print([str(p) for p in backup.iterdir()])
Pro tip: use pathlib's Path instead of gluing folder names together with strings. Path("reports") / "march.xlsx" works on Windows, Mac and Linux.
Generators: Data on Demand with yield#
A list is a printed book of raffle tickets. A generator is the ticket machine: it makes the next one only when you pull.
def ticket_machine(start=1):
number = start
while True:
yield f"Ticket {number:03d}"
number += 1
machine = ticket_machine()
print(next(machine), next(machine), next(machine))
def big_orders(rows, min_value=80):
for row in rows:
value = int(row["quantity"]) * float(row["unit_price"])
if value >= min_value:
yield row["order_id"], row["plant"], value
for order_id, plant, value in big_orders(rows):
print(order_id, f"{plant:<16} £{value:.2f}")
def read_orders(path):
with open(path, newline="", encoding="utf-8") as f:
yield from csv.DictReader(f)
units = sum(int(row["quantity"]) for row in read_orders(csv_path))
busiest = Counter(row["customer"] for row in read_orders(csv_path)).most_common(1)
print("Units sold:", units, "| Most orders:", busiest)
import sys
as_list = [n * n for n in range(1_000_000)]
as_gen = (n * n for n in range(1_000_000))
print(sys.getsizeof(as_list), "bytes vs", sys.getsizeof(as_gen), "bytes")
print("First sum: ", sum(as_gen))
print("Second sum:", sum(as_gen))
Pro tip: sum(x * x for x in data) needs no extra brackets. Use a generator when you only loop once; use a list when you need to reuse or index the results. Quick check: what's the difference between return and yield?
Capstone: An Automated Weekly Sales Report in Excel#
- Summarise the week's orders by plant
- Write a styled worksheet with openpyxl, plus a live formula TOTAL row
- Add the raw orders on a second sheet, save, and open it
- Chart revenue by day with matplotlib
by_plant = {}
for o in orders:
s = by_plant.setdefault(o["plant"], {"orders": 0, "units": 0, "revenue": 0.0})
s["orders"] += 1
s["units"] += o["quantity"]
s["revenue"] += o["quantity"] * o["unit_price"]
ranked = sorted(by_plant.items(), key=lambda kv: kv[1]["revenue"], reverse=True)
for plant, s in ranked:
print(f"{plant:<16}{s['orders']:>3} orders{s['units']:>4} units £{s['revenue']:>8.2f}")
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
wb = Workbook()
ws = wb.active
ws.title = "Sales by plant"
ws.append(["Plant", "Orders", "Units", "Unit price", "Revenue"])
for plant, s in ranked:
ws.append([plant, s["orders"], s["units"], CATALOGUE[plant], s["revenue"]])
print("Filled", ws.dimensions)
last = ws.max_row
total_row = last + 1
ws.append(["TOTAL", f"=SUM(B2:B{last})", f"=SUM(C2:C{last})", None,
f"=SUM(E2:E{last})"])
print(ws[f"B{total_row}"].value, "|", ws[f"E{total_row}"].value)
green = PatternFill("solid", start_color="2E7D32")
for cell in ws[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = green
cell.alignment = Alignment(horizontal="center")
for cell in ws[total_row]:
cell.font = Font(bold=True)
print("Styled header and total row")
for row in ws.iter_rows(min_row=2, min_col=4, max_col=5):
for cell in row:
cell.number_format = "£#,##0.00"
widths = {"A": 18, "B": 9, "C": 9, "D": 12, "E": 13}
for letter, width in widths.items():
ws.column_dimensions[letter].width = width
ws.freeze_panes = "A2"
print("Formats, widths and frozen header set")
from openpyxl.chart import BarChart, Reference
chart = BarChart()
chart.title = "Revenue by plant"
chart.add_data(Reference(ws, min_col=5, min_row=1, max_row=last), titles_from_data=True)
chart.set_categories(Reference(ws, min_col=1, min_row=2, max_row=last))
ws.add_chart(chart, "H2")
print("Native Excel chart added at H2")
raw = wb.create_sheet("Orders")
raw.append(list(orders[0].keys()))
for o in orders:
raw.append(list(o.values()))
report = Path(f"fernway_sales_{week_start.isoformat()}.xlsx")
wb.save(report)
print("Saved", report.name, "with sheets", wb.sheetnames)
from openpyxl import load_workbook
check = load_workbook(report)["Sales by plant"]
for row in check.iter_rows(values_only=True):
print(row)
week_revenue = sum(s["revenue"] for s in by_plant.values())
week_units = sum(s["units"] for s in by_plant.values())
print(f"Excel should show {len(orders)} orders, {week_units} units,")
print(f"and revenue of {fernway_tools.format_gbp(week_revenue)}")
import matplotlib.pyplot as plt
daily = {}
for o in orders:
daily[o["date"]] = daily.get(o["date"], 0) + o["quantity"] * o["unit_price"]
labels = [date.fromisoformat(d).strftime("%a %d") for d in sorted(daily)]
plt.figure(figsize=(8, 3.6))
plt.bar(labels, [daily[d] for d in sorted(daily)], color="#2E7D32")
plt.title("Fernway revenue by day, week of 2 March 2026")
plt.ylabel("Revenue (£)")
plt.tight_layout()
plt.show()
Pro tip: once this works, wrap it in a function like build_report(orders, week_start) and schedule it. Your Monday-morning chore is now a two-second script.
Recap#
- Lists: methods, sorted vs .sort(), key=, the copy trap, 2D lists
- Tuples: fixed records, unpacking, swaps and star unpacking
- Comprehensions for lists, dicts and sets
- Dicts: nesting, get, setdefault, items; Sets: & | - and fast membership
- Functions: *args, **kwargs, LEGB scope, lambda with sorted, max and min
- Errors: try / except / else / finally, raise, custom exceptions
- OOP: classes, init, str / repr, inheritance, super(), dataclasses
- Modules, packages, and the automation toolbox: pathlib, csv, json, datetime, random
- Generators with yield, and an automated Excel report with openpyxl
Try it yourself#
- Wrap the capstone in build_report(orders, week_start) and generate reports for three different weeks (change the seed and start date).
- Add a 'Share' column to the report with a formula like =E2/$E$8, formatted as a percentage.
- Create a dataclass Customer with name and email, and raise a custom InvalidEmailError when the email has no @ sign.
What's next#
You've finished the Python for Beginners series! Next: the Python Data Analytics series, starting with video 1: Acquiring and Auditing a Retail Transactions Dataset.
Found this useful?
All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.



