Warm-up · Activity 1 of 7
Warm-up from lesson 2: constants as class variables. What does this print?
class Status:
OPEN = "open"
CLOSED = "closed"
status = "opne"
print(status == Status.OPEN, status in (Status.OPEN, Status.CLOSED))// I2.6 · ~40 min · Intermediate
In this build you define enums with auto and StrEnum, look members up by value and by name, and combine the module into a bank-account model with transactions, a status and clear errors.
Lesson 6 of 6 in I2 Classes
You will be able to
Warm-up · Activity 1 of 7
class Status:
OPEN = "open"
CLOSED = "closed"
status = "opne"
print(status == Status.OPEN, status in (Status.OPEN, Status.CLOSED))Predict · Activity 2 of 7
from enum import Enum
class Status(Enum):
OPEN = "open"
CLOSED = "closed"
print(Status("open"))
try:
Status("opne")
except ValueError as err:
print(err)Practice · Activity 3 of 7
from enum import Enum
class Status(Enum):
OPEN = "open"
CLOSED = "closed"
print(Status.OPEN.____)Practice · Activity 4 of 7
from enum import Enum, auto
class Level(Enum):
LOW = auto()
MID = auto()
HIGH = auto()
print([m.value for m in Level])Practice · Activity 5 of 7
Brain teaser · Activity 6 of 7
from enum import Enum
class Size(Enum):
S = 1
SMALL = 1
M = 2
print(len(Size), Size.SMALL)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
Status is an Enum whose numbers come from auto(), and a dict maps each status to the next one: members are hashable, so they work as keys. Channel is a StrEnum, so its members compare equal to plain strings and show their value in f-strings. Text from outside, such as "fax", is turned into a member in one place, where a ValueError can be caught.
main.py
from enum import Enum, StrEnum, auto
class Status(Enum):
PENDING = auto()
PAID = auto()
SHIPPED = auto()
class Channel(StrEnum):
WEB = auto()
PHONE = auto()
NEXT = {Status.PENDING: Status.PAID, Status.PAID: Status.SHIPPED}
def advance(status: Status) -> Status:
if status not in NEXT:
raise ValueError(f"{status.name} is the last step")
return NEXT[status]
print([status.name for status in Status])
print(Status(2), Status["SHIPPED"].value, Status.PAID is Status(2))
print(advance(Status.PENDING), repr(advance(Status.PAID)))
print(Channel.WEB == "web", f"via {Channel.PHONE}", Channel("phone").name)
for text in ["web", "fax"]:
try:
print("channel:", Channel(text))
except ValueError as err:
print("refused:", err)
try:
advance(Status.SHIPPED)
except ValueError as err:
print("refused:", err)
Run it with
python main.pyOutput
['PENDING', 'PAID', 'SHIPPED']
Status.PAID 3 True
Status.PAID <Status.SHIPPED: 3>
True via phone PHONE
channel: web
refused: 'fax' is not a valid Channel
refused: SHIPPED is the last stepTab 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 3
Start the bank model with amounts in cents. Make TxKind a StrEnum whose values come from auto(), so TxKind("withdrawal") works. Make Transaction a frozen data class. Its __post_init__ raises ValueError when cents is 0 or less. Add a property signed: cents for a deposit, minus cents for a withdrawal.
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.
Replace the string values with auto(): in a StrEnum, DEPOSIT = auto() gives the value "deposit".
Write @dataclass(frozen=True), and in __post_init__(self) -> None raise ValueError if self.cents <= 0.
The property compares the kind with is: if self.kind is TxKind.DEPOSIT, return self.cents, otherwise -self.cents.
One way to solve it. Yours can look different and still pass the checks.
from dataclasses import dataclass
from enum import StrEnum, auto
class TxKind(StrEnum):
DEPOSIT = auto()
WITHDRAWAL = auto()
@dataclass(frozen=True)
class Transaction:
kind: TxKind
cents: int
def __post_init__(self) -> None:
if self.cents <= 0:
raise ValueError(f"amount must be positive, got {self.cents}")
@property
def signed(self) -> int:
if self.kind is TxKind.DEPOSIT:
return self.cents
return -self.cents
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
from enum import StrEnum, auto
class TxKind(StrEnum):
DEPOSIT = "DEPOSIT"
WITHDRAWAL = "WITHDRAWAL"
@dataclass
class Transaction:
kind: TxKind
cents: int
test_main.py
import dataclasses
from main import Transaction, TxKind
def test_kind_values():
"""auto() gives the lowercase names as values"""
got = [kind.value for kind in TxKind]
assert got == ["deposit", "withdrawal"], f"the values are {got!r}"
def test_lookup():
"""TxKind("withdrawal") finds the member"""
assert TxKind("withdrawal") is TxKind.WITHDRAWAL, 'TxKind("withdrawal") did not give TxKind.WITHDRAWAL'
def test_signed():
"""Deposits count up, withdrawals count down"""
got = (Transaction(TxKind.DEPOSIT, 500).signed, Transaction(TxKind.WITHDRAWAL, 200).signed)
assert got == (500, -200), f"signed gives {got!r}, expected (500, -200)"
def test_rejects_amount():
"""An amount of 0 raises ValueError"""
try:
Transaction(TxKind.DEPOSIT, 0)
except ValueError:
pass
else:
assert False, "Transaction(TxKind.DEPOSIT, 0) was accepted"
def test_frozen():
"""A transaction cannot be changed"""
tx = Transaction(TxKind.DEPOSIT, 500)
try:
tx.cents = 1 # type: ignore[misc]
except dataclasses.FrozenInstanceError:
pass
else:
assert False, "tx.cents = 1 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.pyExercise 2 of 3
Your step 1 code and two exception classes are ready. Rewrite Account: instead of a stored balance, it keeps history, a list of Transaction that is new for every account. balance becomes a read-only property that adds up the signed amounts. deposit and withdraw append a Transaction. withdraw raises InsufficientFundsError(balance, cents) when the amount is larger than the balance.
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.
The field is history: list[Transaction] = field(default_factory=list). Remove the balance field.
The property is @property def balance(self) -> int: return sum(tx.signed for tx in self.history).
In withdraw, check cents > self.balance before appending, so a refused withdrawal leaves no trace. Transaction itself rejects amounts of 0.
One way to solve it. Yours can look different and still pass the checks.
from dataclasses import dataclass, field
from enum import StrEnum, auto
class TxKind(StrEnum):
DEPOSIT = auto()
WITHDRAWAL = auto()
@dataclass(frozen=True)
class Transaction:
kind: TxKind
cents: int
def __post_init__(self) -> None:
if self.cents <= 0:
raise ValueError(f"amount must be positive, got {self.cents}")
@property
def signed(self) -> int:
if self.kind is TxKind.DEPOSIT:
return self.cents
return -self.cents
class AccountError(Exception):
"""Something the account cannot do."""
class InsufficientFundsError(AccountError):
def __init__(self, balance: int, requested: int) -> None:
super().__init__(f"balance {balance} is less than {requested}")
self.balance = balance
self.requested = requested
@dataclass
class Account:
owner: str
history: list[Transaction] = field(default_factory=list)
@property
def balance(self) -> int:
return sum(tx.signed for tx in self.history)
def deposit(self, cents: int) -> None:
self.history.append(Transaction(TxKind.DEPOSIT, cents))
def withdraw(self, cents: int) -> None:
if cents > self.balance:
raise InsufficientFundsError(self.balance, cents)
self.history.append(Transaction(TxKind.WITHDRAWAL, cents))
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, field
from enum import StrEnum, auto
class TxKind(StrEnum):
DEPOSIT = auto()
WITHDRAWAL = auto()
@dataclass(frozen=True)
class Transaction:
kind: TxKind
cents: int
def __post_init__(self) -> None:
if self.cents <= 0:
raise ValueError(f"amount must be positive, got {self.cents}")
@property
def signed(self) -> int:
if self.kind is TxKind.DEPOSIT:
return self.cents
return -self.cents
class AccountError(Exception):
"""Something the account cannot do."""
class InsufficientFundsError(AccountError):
def __init__(self, balance: int, requested: int) -> None:
super().__init__(f"balance {balance} is less than {requested}")
self.balance = balance
self.requested = requested
@dataclass
class Account:
owner: str
balance: int = 0
def deposit(self, cents: int) -> None:
self.balance += cents
def withdraw(self, cents: int) -> None:
self.balance -= cents
test_main.py
from main import Account, InsufficientFundsError, TxKind
def test_starts_empty():
"""A new account has an empty history and balance 0"""
acct = Account("ada")
assert (getattr(acct, "history", None), acct.balance) == ([], 0), f"history {getattr(acct, 'history', None)!r}, balance {acct.balance!r}"
def test_balance_from_history():
"""The balance is computed from the history"""
acct = Account("ada")
acct.deposit(1000)
acct.withdraw(300)
kinds = [tx.kind for tx in getattr(acct, "history", [])]
assert kinds == [TxKind.DEPOSIT, TxKind.WITHDRAWAL], f"the history holds {kinds!r}"
assert acct.balance == 700, f"balance is {acct.balance!r}, expected 700"
def test_own_history():
"""Every account has its own history"""
a = Account("ada")
b = Account("bob")
a.deposit(500)
got = getattr(b, "history", None)
assert got == [], f"after a deposit on a, b.history is {got!r}"
def test_insufficient_funds():
"""Withdrawing too much raises InsufficientFundsError"""
acct = Account("ada")
acct.deposit(100)
try:
acct.withdraw(250)
except InsufficientFundsError as err:
assert (err.balance, err.requested) == (100, 250), f"the error holds {(err.balance, err.requested)!r}"
else:
assert False, "withdraw(250) with balance 100 was accepted"
assert acct.balance == 100, f"after the refused withdrawal, balance is {acct.balance!r}"
def test_rejects_zero():
"""deposit(0) raises ValueError"""
try:
Account("ada").deposit(0)
except ValueError:
pass
else:
assert False, "deposit(0) 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 3 of 3
Give Status the members OPEN and FROZEN with auto(), and give Account a field status that starts as Status.OPEN. deposit and withdraw raise AccountError("account is frozen") unless the account is open. apply(rows) books rows such as ("deposit", 500): look the kind up with TxKind(text). A row that fails with ValueError or AccountError is skipped and reported as "row 2: " plus the message. Return the reports.
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.
Add status: Status = Status.OPEN after history. A member is a fine default: it is not a list.
A helper _check_open raises AccountError(f"account is {self.status.name.lower()}") when self.status is not Status.OPEN; call it first in deposit and withdraw.
Loop with for number, (kind_text, cents) in enumerate(rows, start=1), and catch (ValueError, AccountError) as err around the lookup and the booking.
One way to solve it. Yours can look different and still pass the checks.
from dataclasses import dataclass, field
from enum import Enum, StrEnum, auto
class TxKind(StrEnum):
DEPOSIT = auto()
WITHDRAWAL = auto()
@dataclass(frozen=True)
class Transaction:
kind: TxKind
cents: int
def __post_init__(self) -> None:
if self.cents <= 0:
raise ValueError(f"amount must be positive, got {self.cents}")
@property
def signed(self) -> int:
if self.kind is TxKind.DEPOSIT:
return self.cents
return -self.cents
class AccountError(Exception):
"""Something the account cannot do."""
class InsufficientFundsError(AccountError):
def __init__(self, balance: int, requested: int) -> None:
super().__init__(f"balance {balance} is less than {requested}")
self.balance = balance
self.requested = requested
class Status(Enum):
OPEN = auto()
FROZEN = auto()
@dataclass
class Account:
owner: str
history: list[Transaction] = field(default_factory=list)
status: Status = Status.OPEN
@property
def balance(self) -> int:
return sum(tx.signed for tx in self.history)
def _check_open(self) -> None:
if self.status is not Status.OPEN:
raise AccountError(f"account is {self.status.name.lower()}")
def deposit(self, cents: int) -> None:
self._check_open()
self.history.append(Transaction(TxKind.DEPOSIT, cents))
def withdraw(self, cents: int) -> None:
self._check_open()
if cents > self.balance:
raise InsufficientFundsError(self.balance, cents)
self.history.append(Transaction(TxKind.WITHDRAWAL, cents))
def apply(self, rows: list[tuple[str, int]]) -> list[str]:
problems: list[str] = []
for number, (kind_text, cents) in enumerate(rows, start=1):
try:
kind = TxKind(kind_text)
if kind is TxKind.DEPOSIT:
self.deposit(cents)
else:
self.withdraw(cents)
except (ValueError, AccountError) as err:
problems.append(f"row {number}: {err}")
return problems
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, field
from enum import Enum, StrEnum, auto
class TxKind(StrEnum):
DEPOSIT = auto()
WITHDRAWAL = auto()
@dataclass(frozen=True)
class Transaction:
kind: TxKind
cents: int
def __post_init__(self) -> None:
if self.cents <= 0:
raise ValueError(f"amount must be positive, got {self.cents}")
@property
def signed(self) -> int:
if self.kind is TxKind.DEPOSIT:
return self.cents
return -self.cents
class AccountError(Exception):
"""Something the account cannot do."""
class InsufficientFundsError(AccountError):
def __init__(self, balance: int, requested: int) -> None:
super().__init__(f"balance {balance} is less than {requested}")
self.balance = balance
self.requested = requested
class Status(Enum):
"""Add the members OPEN and FROZEN."""
@dataclass
class Account:
owner: str
history: list[Transaction] = field(default_factory=list)
@property
def balance(self) -> int:
return sum(tx.signed for tx in self.history)
def deposit(self, cents: int) -> None:
self.history.append(Transaction(TxKind.DEPOSIT, cents))
def withdraw(self, cents: int) -> None:
if cents > self.balance:
raise InsufficientFundsError(self.balance, cents)
self.history.append(Transaction(TxKind.WITHDRAWAL, cents))
def apply(self, rows: list[tuple[str, int]]) -> list[str]:
return []
test_main.py
from main import Account, AccountError, Status
def test_status_members():
"""Status has OPEN and FROZEN, in that order"""
got = [status.name for status in Status]
assert got == ["OPEN", "FROZEN"], f"the members are {got!r}"
def test_starts_open():
"""A new account is open"""
got = getattr(Account("ada"), "status", None)
assert got is Status.OPEN, f"the status of a new account is {got!r}"
def test_frozen_refuses():
"""A frozen account refuses deposits with AccountError"""
acct = Account("ada", status=Status.FROZEN)
try:
acct.deposit(500)
except AccountError as err:
assert str(err) == "account is frozen", f"the message is {str(err)!r}"
else:
assert False, "a frozen account accepted a deposit"
def test_apply_good_rows():
"""apply books valid rows and returns no problems"""
acct = Account("ada")
problems = acct.apply([("deposit", 1000), ("withdrawal", 400)])
assert (problems, acct.balance) == ([], 600), f"problems {problems!r}, balance {acct.balance!r}"
def test_apply_collects_problems():
"""apply skips bad rows and reports them by number"""
acct = Account("ada")
rows = [("deposit", 500), ("refund", 100), ("withdrawal", 900), ("deposit", 0), ("withdrawal", 200)]
problems = acct.apply(rows)
expected = [
"row 2: 'refund' is not a valid TxKind",
"row 3: balance 500 is less than 900",
"row 4: amount must be positive, got 0",
]
assert problems == expected, f"apply returned {problems!r}"
assert acct.balance == 300, f"balance is {acct.balance!r}, expected 300"
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 enum import Enum
class Status(Enum):
OPEN = "open"
CLOSED = "closed"
status = Status("opne")
What Python prints
ValueError: 'opne' is not a valid StatusWhy, and the fix
This error is the enum doing its job: the text is not one of the allowed values. When the text comes from a user or a file, convert it in one place and catch ValueError there, to ask again or to report the row. Do not fall back to the raw string, or the typo travels on.
from enum import Enum
class Level(Enum):
LOW = 1
HIGH = 2
print(Level.LOW < Level.HIGH)
What Python prints
TypeError: '<' not supported between instances of 'Level' and 'Level'Why, and the fix
Enum members support == and is, but not <, even when their values are numbers. Compare the values instead, Level.LOW.value < Level.HIGH.value, or keep an explicit order such as list(Level).index(member).
from enum import Enum
class Color(Enum):
RED = 1
GREEN = 2
print(Color["red"])
What Python prints
KeyError: 'red'Why, and the fix
Square brackets look up the member name exactly as it is written in the class, here RED. Normalise the text first, Color[text.strip().upper()], and catch KeyError for names that still do not exist. If the text is a value rather than a name, use round brackets.
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.