Warm-up · Activity 1 of 7
Warm-up from the last lessons: a plain class with an __init__. What does this print?
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
print(Point(1, 2) == Point(1, 2))// I2.5 · ~35 min · Intermediate
After this lesson you can replace hand-written __init__ and __repr__ with @dataclass, give fields safe defaults, check values in __post_init__, and make instances frozen and sortable.
You will be able to
Warm-up · Activity 1 of 7
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
print(Point(1, 2) == Point(1, 2))Predict · Activity 2 of 7
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p = Point(1, 2)
print(p, p == Point(1, 2))Practice · Activity 3 of 7
from dataclasses import dataclass, field
@dataclass
class Basket:
items: list[str] = ____(default_factory=list)Practice · Activity 4 of 7
from dataclasses import dataclass, field
@dataclass
class Rect:
w: int
h: int
area: int = field(init=False)
def __post_init__(self):
self.area = self.w * self.h
print(Rect(2, 3))Practice · Activity 5 of 7
Brain teaser · Activity 6 of 7
from dataclasses import dataclass
@dataclass(frozen=True)
class Team:
name: str
members: list
t = Team("A", [])
t.members.append("ada")
print(t)Apply · Activity 7 of 7
Check your work against this list
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
Three data classes work together. Money is frozen and ordered, so amounts can be compared, used in sets and never changed by accident. LineItem checks its quantity in __post_init__. Order starts with its own empty list thanks to default_factory. None of them has a hand-written __init__ or __repr__.
main.py
from dataclasses import dataclass, field
@dataclass(frozen=True, order=True)
class Money:
cents: int
currency: str = "EUR"
def text(self) -> str:
return f"{self.cents / 100:.2f} {self.currency}"
@dataclass
class LineItem:
name: str
price: Money
quantity: int = 1
def __post_init__(self) -> None:
if self.quantity < 1:
raise ValueError(f"quantity must be at least 1, got {self.quantity}")
def total(self) -> Money:
return Money(self.price.cents * self.quantity, self.price.currency)
@dataclass
class Order:
customer: str
items: list[LineItem] = field(default_factory=list)
def add(self, name: str, cents: int, quantity: int = 1) -> None:
self.items.append(LineItem(name, Money(cents), quantity))
def total(self) -> Money:
return Money(sum(item.total().cents for item in self.items))
order = Order("ada")
order.add("coffee", 850, 2)
order.add("mug", 1200)
print(order.items[1])
print(order.total().text(), Order("bob").items)
print(max(item.total() for item in order.items).text())
print(Money(500) == Money(500), len({Money(500), Money(500), Money(200)}))
try:
order.add("tea", 420, 0)
except ValueError as err:
print("refused:", err)
Run it with
python main.pyOutput
LineItem(name='mug', price=Money(cents=1200, currency='EUR'), quantity=1)
29.00 EUR []
17.00 EUR
True 2
refused: quantity must be at least 1, got 0Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Exercise 1 of 2
Rewrite Contact as a data class with the fields name, email and tags, where tags defaults to a new empty list for every contact. In __post_init__, strip and lowercase the email, and raise ValueError if it has no @. tag(label) adds a label only if it is not there yet. The starter shares one list between all contacts: the tests catch that.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Import dataclass and field, put @dataclass above the class, and replace __init__ with three annotated fields.
tags: list[str] = field(default_factory=list) gives every contact a new list.
In __post_init__(self) -> None, assign self.email = self.email.strip().lower() first, then check for "@".
One way to solve it. Yours can look different and still pass the checks.
from dataclasses import dataclass, field
@dataclass
class Contact:
name: str
email: str
tags: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
self.email = self.email.strip().lower()
if "@" not in self.email:
raise ValueError(f"not an email address: {self.email!r}")
def tag(self, label: str) -> None:
if label not in self.tags:
self.tags.append(label)
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
class Contact:
def __init__(self, name: str, email: str, tags: list[str] = []) -> None:
self.name = name
self.email = email
self.tags = tags
def tag(self, label: str) -> None:
self.tags.append(label)
test_main.py
from main import Contact
def test_repr():
"""The data class shows its fields"""
got = repr(Contact("Ada", "ada@example.com"))
assert got == "Contact(name='Ada', email='ada@example.com', tags=[])", f"repr is {got}"
def test_equal():
"""Two contacts with the same data are equal"""
assert Contact("Ada", "ada@example.com") == Contact("Ada", "ada@example.com"), "equal contacts compare as different"
def test_own_tags():
"""Every contact has its own tag list"""
a = Contact("Ada", "ada@example.com")
b = Contact("Bob", "bob@example.com")
a.tag("vip")
assert b.tags == [], f"tagging a also changed b.tags to {b.tags!r}"
def test_tag_once():
"""A tag is stored only once"""
c = Contact("Ada", "ada@example.com")
c.tag("vip")
c.tag("vip")
assert c.tags == ["vip"], f"after tagging vip twice, tags is {c.tags!r}"
def test_normalises_email():
"""The email is stripped and lowercased"""
got = Contact("Ada", " Ada@Example.COM ").email
assert got == "ada@example.com", f"email is {got!r}"
def test_rejects_email():
"""An email without @ raises ValueError"""
try:
Contact("Ada", "ada.example.com")
except ValueError:
pass
else:
assert False, 'Contact("Ada", "ada.example.com") was accepted'
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 2 of 2
As text, "1.9.3" sorts after "1.10.0". Make Version a frozen, ordered data class with major, minor and patch (both default 0), and give it a __str__ that returns "1.10.0". parse_version(text) turns "1.10.0" into Version(1, 10, 0). newest(texts) returns the newest version as a string, comparing numbers, not text.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Change the decorator to @dataclass(frozen=True, order=True). Comparing then works field by field: major first, then minor, then patch.
In parse_version, split the text on "." and convert each part with int().
newest can parse every text, take max() of the Version objects, and return str() of the result.
One way to solve it. Yours can look different and still pass the checks.
from dataclasses import dataclass
@dataclass(frozen=True, order=True)
class Version:
major: int
minor: int = 0
patch: int = 0
def __str__(self) -> str:
return f"{self.major}.{self.minor}.{self.patch}"
def parse_version(text: str) -> Version:
major, minor, patch = (int(part) for part in text.split("."))
return Version(major, minor, patch)
def newest(texts: list[str]) -> str:
return str(max(parse_version(text) for text in texts))
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
from dataclasses import dataclass
@dataclass
class Version:
major: int
minor: int = 0
patch: int = 0
def parse_version(text: str) -> Version:
return Version(0)
def newest(texts: list[str]) -> str:
return max(texts)
test_main.py
import dataclasses
from main import Version, newest, parse_version
def test_parse():
"""parse_version reads three numbers"""
got = parse_version("1.10.3")
assert got == Version(1, 10, 3), f'parse_version("1.10.3") is {got!r}'
def test_str():
"""str() gives the dotted form"""
got = str(Version(2, 0, 1))
assert got == "2.0.1", f"str(Version(2, 0, 1)) is {got!r}"
def test_order():
"""Versions compare number by number"""
assert Version(1, 10, 0) > Version(1, 9, 3), "Version(1, 10, 0) is not greater than Version(1, 9, 3)"
def test_newest():
"""newest compares numbers, not text"""
got = newest(["1.9.3", "1.10.0", "1.2.0"])
assert got == "1.10.0", f"newest gave {got!r}"
def test_hashable():
"""Equal versions count once in a set"""
got = len({parse_version("1.2.0"), parse_version("1.2.0"), parse_version("1.2.1")})
assert got == 2, f"the set holds {got} versions, expected 2"
def test_frozen():
"""A version cannot be changed"""
v = Version(1, 2, 3)
try:
v.major = 9 # type: ignore[misc]
except dataclasses.FrozenInstanceError:
pass
else:
assert False, "v.major = 9 was accepted; use frozen=True"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyfrom dataclasses import dataclass
@dataclass
class Cart:
owner: str
items: list = []
What Python prints
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factoryWhy, and the fix
A default is evaluated once, so every Cart would share the same list, and items added to one cart would show up in all of them. @dataclass catches this for lists, dicts and sets, and fails when the class is defined. Write items: list = field(default_factory=list), and import field from dataclasses.
from dataclasses import dataclass
@dataclass
class Order:
express: bool = False
customer: str
print(Order("ada"))
What Python prints
TypeError: non-default argument 'customer' follows default argument 'express'Why, and the fix
The generated __init__ takes the fields in the order they are written, and a parameter without a default cannot follow one with a default, just as in a normal def. Move the fields with defaults to the end: customer: str first, then express: bool = False.
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
visited = {Point(0, 0), Point(1, 2)}
print(len(visited))
What Python prints
TypeError: cannot use 'Point' as a set element (unhashable type: 'Point')Why, and the fix
A data class with the generated __eq__ has no hash, because its fields could change after it is stored in a set. If the points never change, write @dataclass(frozen=True): frozen instances get a hash built from their fields, so they work in sets and as dict keys.
Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.