Skip to content
aviral gupta

// I2.6 · ~40 min · Intermediate

Build: enums and a bank-account model

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

End of the module

You will be able to

  • Define an Enum or StrEnum with fixed values or auto(), and use each member's name and value
  • Look members up by value and by name, iterate over them, and handle unknown input
  • Compare members with is and build an account model from enums, data classes and exceptions
  1. 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))
  2. Predict · Activity 2 of 7

    Predict before you read on: Status is now an Enum. What does this print?

    from enum import Enum
    
    
    class Status(Enum):
        OPEN = "open"
        CLOSED = "closed"
    
    
    print(Status("open"))
    try:
        Status("opne")
    except ValueError as err:
        print(err)
  3. Practice · Activity 3 of 7

    Fill in the attribute so the last line prints OPEN.

    from enum import Enum
    
    
    class Status(Enum):
        OPEN = "open"
        CLOSED = "closed"
    
    
    print(Status.OPEN.____)
    Status.OPEN.)
  4. Practice · Activity 4 of 7

    The values come from auto(). What does this print?

    from enum import Enum, auto
    
    
    class Level(Enum):
        LOW = auto()
        MID = auto()
        HIGH = auto()
    
    
    print([m.value for m in Level])
  5. Practice · Activity 5 of 7

    Color has RED = 1, GREEN = 2 and BLUE = 3. Match each expression to what it gives.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. Two names share the value 1. What does this print?

    from enum import Enum
    
    
    class Size(Enum):
        S = 1
        SMALL = 1
        M = 2
    
    
    print(len(Size), Size.SMALL)
  7. Apply · Activity 7 of 7

    Mini-task. Write an enum Priority with LOW, MEDIUM and HIGH, using auto(). Write parse_priority(text) that strips and uppercases the text and looks the member up by name. For an unknown name, raise ValueError("unknown priority: 'urgent'") instead of the KeyError. parse_priority(" high ") gives Priority.HIGH.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Order status and sales channel

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.py

Output

['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 step
  • Status(2) looks up by value, Status["SHIPPED"] by name; both return the one member object.
  • repr() shows the name and the value: <Status.SHIPPED: 3>.
  • print(Channel(text)) shows web, not Channel.WEB: a StrEnum prints like its value.
  • The dict NEXT uses members as keys, and status not in NEXT finds the final step.
Change it and run it

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.

Exercises

Exercise 1 of 3

Step 1: transaction kinds and transactions

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.

Hints
  1. Hint 1

    Replace the string values with auto(): in a StrEnum, DEPOSIT = auto() gives the value "deposit".

  2. Hint 2

    Write @dataclass(frozen=True), and in __post_init__(self) -> None raise ValueError if self.cents <= 0.

  3. Hint 3

    The property compares the kind with is: if self.kind is TxKind.DEPOSIT, return self.cents, otherwise -self.cents.

Show a solution

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
Run it on your computer

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.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 3

Step 2: an account with a history

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.

Hints
  1. Hint 1

    The field is history: list[Transaction] = field(default_factory=list). Remove the balance field.

  2. Hint 2

    The property is @property def balance(self) -> int: return sum(tx.signed for tx in self.history).

  3. Hint 3

    In withdraw, check cents > self.balance before appending, so a refused withdrawal leaves no trace. Transaction itself rejects amounts of 0.

Show a solution

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))
Run it on your computer

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.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 3 of 3

Step 3: account status and imported rows

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.

Hints
  1. Hint 1

    Add status: Status = Status.OPEN after history. A member is a fine default: it is not a list.

  2. Hint 2

    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.

  3. Hint 3

    Loop with for number, (kind_text, cents) in enumerate(rows, start=1), and catch (ValueError, AccountError) as err around the lookup and the booking.

Show a solution

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
Run it on your computer

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.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Looking up a value no member has

from enum import Enum


class Status(Enum):
    OPEN = "open"
    CLOSED = "closed"


status = Status("opne")

What Python prints

ValueError: 'opne' is not a valid Status

Why, 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.

Ordering enum members with <

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).

Looking up a name in the wrong case

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

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

An enum is a fixed set of named values

class Status(Enum) with OPEN = "open" and CLOSED = "closed" creates exactly two members. Each has a name, Status.OPEN.name is "OPEN", and a value, Status.OPEN.value is "open". Members cannot be reassigned, and a typo such as Status.OPNE fails at once instead of silently storing a wrong string. When the values do not matter, write OPEN = auto(): Enum numbers the members 1, 2, 3, and StrEnum uses the lowercased name. print(Status.OPEN) shows Status.OPEN.

Lookup and iteration

Status("open") finds the member with that value, and Status["OPEN"] the member with that name. An unknown value raises ValueError, an unknown name KeyError, so convert outside input to a member once, at the edge of the program, and catch the error there. for status in Status gives the members in the order they were defined, and len(Status) counts them. A second name for an existing value is an alias: it finds the same member, but iteration skips it.

Comparing, and the build

Each member exists once, so compare with is: status is Status.OPEN. == works too, but a member is never equal to its raw value, and < is not supported. StrEnum members are strings as well: Kind.DEPOSIT == "deposit" is True, and an f-string shows the value. Members are hashable, so they work as dict keys and data class defaults. The build combines the module: a StrEnum for transaction kinds, a frozen Transaction, an Account with its own history, a computed balance, custom exceptions and a Status enum.

Sources

Last reviewed September 29, 2026