Mathew K Analytics

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,…

⬇ Download notebook

What you'll learn

Data

No separate download needed — the notebook creates or downloads everything it uses.

📓 Full notebook

Download .ipynb

Python 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")
['fiddle leaf fig', 'monstera', 'pothos', 'aloe', 'peace lily', 'zz plant', 'pothos']
7 items, 2 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)
Popped: pothos
aloe is at index 2
['fiddle leaf fig', 'monstera', 'aloe', 'peace lily', 'zz plant']
print(basket[:2])     # first two
print(basket[-2:])    # last two
print(basket[::-1])   # reversed copy
['fiddle leaf fig', 'monstera']
['peace lily', 'zz plant']
['zz plant', 'peace lily', 'aloe', 'monstera', 'fiddle leaf fig']
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
[9.5, 12.75, 18.0, 24.0, 42.0]
[24.0, 9.5, 42.0, 12.75, 18.0]
[42.0, 24.0, 18.0, 12.75, 9.5]

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))
['aloe', 'pothos', 'monstera', 'zz plant', 'fiddle leaf fig']
['Amara', 'Zoe', 'bea', 'oscar']
['Amara', 'bea', 'oscar', 'Zoe']

The Copy Trap and 2D Lists#

Common mistake: two names, one list#

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:  ['monstera', 'pothos', 'aloe', 'calathea']
Same object? True
monday_stock = ["monstera", "pothos", "aloe"]
tuesday_stock = monday_stock.copy()
tuesday_stock.append("calathea")
print("Monday: ", monday_stock)
print("Tuesday:", tuesday_stock)
Monday:  ['monstera', 'pothos', 'aloe']
Tuesday: ['monstera', 'pothos', 'aloe', 'calathea']
shelves = [
    ["monstera", "pothos", "aloe"],
    ["calathea", "zz plant", "fern"],
    ["cactus", "ivy", "orchid"],
]
print(shelves[1])
print(shelves[1][2])
['calathea', 'zz plant', 'fern']
fern
for number, shelf in enumerate(shelves, start=1):
    print(f"Shelf {number}: {' | '.join(shelf)}")
print("Bottom-right corner:", shelves[-1][-1])
Shelf 1: monstera | pothos | aloe
Shelf 2: calathea | zz plant | fern
Shelf 3: cactus | ivy | orchid
Bottom-right corner: orchid
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])
plan_b: bonsai | plan_c: monstera

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))
40 cm long
<class 'tuple'> 3

Common mistake: trying to change a tuple#

box_size[0] = 45
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[13], line 1
----> 1 box_size[0] = 45

TypeError: 'tuple' object does not support item assignment
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")
45 x 30 x 25 cm holds 33.75 litres
opening, closing = "Priya", "Tomasz"
opening, closing = closing, opening
print("Opening:", opening, "| Closing:", closing)
Opening: Tomasz | Closing: Priya
daily_orders = [14, 9, 11, 17, 8, 21, 19]
monday, *midweek, sunday = daily_orders
print(monday, midweek, sunday)
14 [9, 11, 17, 8, 21] 19
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}")
Prices run from £9.50 to £42.00
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}")
monstera   7 left
pothos     SOLD OUT
aloe       12 left

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)
[38.4, 15.0, 10.8, 54.0, 21.6]
with_vat = [round(price * 1.2, 2) for price in prices]
print(with_vat)
[38.4, 15.0, 10.8, 54.0, 21.6]
bargains = [price for price in prices if price < 20]
tiers = ["premium" if price >= 30 else "standard" for price in prices]
print(bargains)
print(tiers)
[12.5, 9.0, 18.0]
['premium', 'standard', 'standard', 'premium', 'standard']
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))
Anything over £40? True
All under £50?    True
Total with VAT:   139.8
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)
['bonsai', 'pothos', 'aloe', 'calathea', 'zz plant', 'fern', 'cactus', 'ivy', 'orchid']
[[0, 0, 0], [0, 0, 0]]

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"])
{'name': 'Monstera', 'price': 32.0, 'stock': 7}
32.0

Common mistake: asking for a key that isn't there#

catalogue["FIG-04"]
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In[25], line 1
----> 1 catalogue["FIG-04"]

KeyError: 'FIG-04'
print(catalogue.get("FIG-04"))
print(catalogue.get("FIG-04", "not in the catalogue"))
print("FIG-04" in catalogue)
None
not in the catalogue
False
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}")
MON-01  Monstera           7 in stock  £ 224.00
POT-02  Golden Pothos     20 in stock  £ 250.00
ALO-03  Aloe Vera         12 in stock  £ 108.00
FIG-04  Fiddle Leaf Fig    3 in stock  £ 135.00
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)
{'amara': ['MON-01', 'POT-02', 'ALO-03'], 'bea': ['ALO-03', 'FIG-04'], 'oscar': ['ALO-03']}
units_sold = {}
for _, code in sales:
    units_sold[code] = units_sold.get(code, 0) + 1
print(units_sold)
{'MON-01': 1, 'ALO-03': 3, 'POT-02': 1, 'FIG-04': 1}
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)
{'MON-01': 32.0, 'POT-02': 12.5, 'ALO-03': 9.0, 'FIG-04': 45.0}
{'Monstera': 7, 'Fiddle Leaf Fig': 3}
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))
{'font': 'Georgia', 'colour': 'pink', 'logo': True, 'banner': 'Spring sale!'}
[('ALO-03', 3), ('MON-01', 1)]

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))
6 tags, 4 unique
['indoor', 'low light', 'pet safe', 'trailing']
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))
Came back:      ['bea', 'kofi', 'lena']
All customers:  ['amara', 'bea', 'kofi', 'lena', 'mateo', 'oscar', 'yusuf']
Did not return: ['amara', 'oscar']
New in week 2:  ['mateo', 'yusuf']
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)
E8 -> we deliver
W4 -> collection only
SE1 -> we deliver
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)
['bea@mail.com', 'kofi@mail.com', 'lena@mail.com']
All VIPs ordered? True

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())
44.5
56.7
0
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))
ALOE VERA (light: bright, water: every 3 weeks)
SNAKE PLANT (light: low, water: monthly, pet_safe: no)

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
Welcome to Fernway!
19 characters
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))
25.6 10.0

Common mistake: changing a global inside a function#

orders_today = 0

def record_order():
    orders_today += 1

record_order()
---------------------------------------------------------------------------
UnboundLocalError                         Traceback (most recent call last)
Cell In[40], line 6
      3 def record_order():
      4     orders_today += 1
----> 6 record_order()

Cell In[40], line 4, in record_order()
      3 def record_order():
----> 4     orders_today += 1

UnboundLocalError: cannot access local variable 'orders_today' where it is not associated with a value
def record_order(count):
    return count + 1

orders_today = record_order(orders_today)
orders_today = record_order(orders_today)
print("Orders today:", orders_today)
Orders today: 2

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])
['Aloe', 'Pothos', 'Monstera', 'Fig']
Most stock value: ('Pothos', 12.5, 20)
Lowest stock: Fig
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")
delivery  Kofi    35 parcels
delivery  Tomasz  27 parcels
packing   Priya   31 parcels
packing   Ines    31 parcels

Your turn:

  1. Write average_price(*prices) that returns 0 when no prices are given.
  2. Sort the products list by stock, highest first, with sorted, a lambda and reverse=True.

Error Handling Done Right#

Common mistake: trusting user input#

pots = int("twelve")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[44], line 1
----> 1 pots = int("twelve")

ValueError: invalid literal for int() with base 10: '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
How many pots would you like? twelve
'twelve' isn't a whole number, please use digits.
How many pots would you like? 12
Great, 12 pots added to your basket.
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"))
(lookup for MON-01 logged)
7
Unknown product FIG-04
(lookup for FIG-04 logged)
0
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)
34.0
Rejected: discount must be 0-50%, got 80%
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")
Reserved 2 x MON-01, 5 left
Could not reserve -> FIG-04: wanted 5, only 3 left
Reserved 4 x MON-01, 1 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)
Monstera 7 | Aloe Vera 0
10 aloes now
houseplant houseplant
<__main__.Plant object at 0x000002047D377140>

Common mistake: forgetting a required argument#

fern = Plant("Boston Fern")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[52], line 1
----> 1 fern = Plant("Boston Fern")

TypeError: Plant.__init__() missing 1 required positional argument: 'price'
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}")
Boston Fern - £14.00 (5 in stock)
[Plant('Boston Fern', 14.0, 5), Plant('Ivy', 8.5, 9)]
Stock value: £70.00
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)
indoor plant
fern | indoor plant

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))
Tulip Bulbs - £6.00 (40 in stock) [best in autumn]
Inherited method: 240.0
True False
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}")
Boston Fern - £14.00 (5 in stock)
Tulip Bulbs - £6.00 (40 in stock) [best in autumn]
Calathea - £22.00 (4 in stock)
Total stock value: £398.00
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))
OrderLine(plant='Monstera', quantity=2, unit_price=32.0)
64.0
True
@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()
Fernway receipt for Amara
  2 x Monstera       £  64.00
  3 x Aloe Vera      £  27.00
  1 x Plant Food     £   6.50
  TOTAL              £  97.50

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}"
Writing fernway_tools.py
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)
38.4
£1,234.50 | VAT rate: 0.2
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()))
['__init__.py', 'shipping.py']
from fernway.shipping import shipping_cost
print("1.5 kg:", shipping_cost(1.5), "| 5 kg:", shipping_cost(5))
1.5 kg: 4.99 | 5 kg: 9.49

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))
Pothos
2 22.77
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"))
Monday 02 March 2026
Week ends: 2026-03-08
18 days until the spring sale
Opened at 09:30
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])
40 orders. The first one:
{'order_id': 1001, 'date': '2026-03-02', 'customer': 'Amara', 'plant': 'Snake Plant', 'quantity': 3, 'unit_price': 18.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")
Saved orders_2026-03-02.csv - 1682 bytes
with csv_path.open(newline="", encoding="utf-8") as f:
    rows = list(csv.DictReader(f))
print(rows[0])
print(type(rows[0]["quantity"]))
{'order_id': '1001', 'date': '2026-03-02', 'customer': 'Amara', 'plant': 'Snake Plant', 'quantity': '3', 'unit_price': '18.0'}
<class 'str'>

Common mistake: saving a date as JSON#

import json
summary = {"week_start": week_start, "orders": len(orders)}
json.dumps(summary)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[73], line 3
      1 import json
      2 summary = {"week_start": week_start, "orders": len(orders)}
----> 3 json.dumps(summary)

File ~\AppData\Local\Programs\Python\Python312\Lib\json\__init__.py:231, in dumps(obj, skipkeys, ensure_ascii, check_circular, allow_nan, cls, indent, separators, default, sort_keys, **kw)
    226 # cached encoder
    227 if (not skipkeys and ensure_ascii and
    228     check_circular and allow_nan and
    229     cls is None and indent is None and separators is None and
    230     default is None and not sort_keys and not kw):
--> 231     return _default_encoder.encode(obj)
    232 if cls is None:
    233     cls = JSONEncoder

File ~\AppData\Local\Programs\Python\Python312\Lib\json\encoder.py:200, in JSONEncoder.encode(self, o)
    196         return encode_basestring(o)
    197 # This doesn't pass the iterator directly to ''.join() because the
    198 # exceptions aren't as detailed.  The list call should be roughly
    199 # equivalent to the PySequence_Fast that ''.join() would do.
--> 200 chunks = self.iterencode(o, _one_shot=True)
    201 if not isinstance(chunks, (list, tuple)):
    202     chunks = list(chunks)

File ~\AppData\Local\Programs\Python\Python312\Lib\json\encoder.py:258, in JSONEncoder.iterencode(self, o, _one_shot)
    253 else:
    254     _iterencode = _make_iterencode(
    255         markers, self.default, _encoder, self.indent, floatstr,
    256         self.key_separator, self.item_separator, self.sort_keys,
    257         self.skipkeys, _one_shot)
--> 258 return _iterencode(o, 0)

File ~\AppData\Local\Programs\Python\Python312\Lib\json\encoder.py:180, in JSONEncoder.default(self, o)
    161 def default(self, o):
    162     """Implement this method in a subclass such that it returns
    163     a serializable object for ``o``, or calls the base implementation
    164     (to raise a ``TypeError``).
   (...)
    178 
    179     """
--> 180     raise TypeError(f'Object of type {o.__class__.__name__} '
    181                     f'is not JSON serializable')

TypeError: Object of type date is not JSON serializable
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")
40 orders from 8 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")
orders_2026-03-02.csv    .csv     1682 bytes
week_summary.json        .json     181 bytes
fernway_tools.py         .py       194 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()])
['backup\\20260309-0800\\orders_2026-03-02.csv']

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))
Ticket 001 Ticket 002 Ticket 003
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}")
1013 Fiddle Leaf Fig  £135.00
1036 Fiddle Leaf Fig  £90.00
1021 Fiddle Leaf Fig  £90.00
1032 Fiddle Leaf Fig  £135.00
1023 Monstera         £128.00
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)
Units sold: 89 | Most orders: [('Lena', 9)]
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))
8448728
 bytes vs 200 bytes
First sum:  333332833333500000
Second sum: 0

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#

  1. Summarise the week's orders by plant
  2. Write a styled worksheet with openpyxl, plus a live formula TOTAL row
  3. Add the raw orders on a second sheet, save, and open it
  4. 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}")
Fiddle Leaf Fig   5 orders  11 units  £  495.00
Monstera          7 orders  14 units  £  448.00
Snake Plant       9 orders  21 units  £  378.00
Golden Pothos     8 orders  20 units  £  250.00
Calathea          6 orders   9 units  £  198.00
Aloe Vera         5 orders  14 units  £  126.00
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)
Filled A1:E7
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)
=SUM(B2:B7) | =SUM(E2:E7)
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")
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")
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")
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)
Saved fernway_sales_2026-03-02.xlsx with sheets ['Sales by plant', 'Orders']
from openpyxl import load_workbook
check = load_workbook(report)["Sales by plant"]
for row in check.iter_rows(values_only=True):
    print(row)
('Plant', 'Orders', 'Units', 'Unit price', 'Revenue')
('Fiddle Leaf Fig', 5, 11, 45, 495)
('Monstera', 7, 14, 32, 448)
('Snake Plant', 9, 21, 18, 378)
('Golden Pothos', 8, 20, 12.5, 250)
('Calathea', 6, 9, 22, 198)
('Aloe Vera', 5, 14, 9, 126)
('TOTAL', '=SUM(B2:B7)', '=SUM(C2:C7)', None, '=SUM(E2:E7)')
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)}")
Excel should show 40 orders, 89 units,
and revenue of £1,895.00
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()
No description has been provided for this image

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#

  1. Wrap the capstone in build_report(orders, week_start) and generate reports for three different weeks (change the seed and start date).
  2. Add a 'Share' column to the report with a formula like =E2/$E$8, formatted as a percentage.
  3. 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.