Python classes practice — three programs: an account that can’t be tampered with, the faculty library and a copy shop with VAT for all (and the −4000 € balance nobody stopped)

The first BankAccount class I wrote for a lab worked beautifully in my tests: deposit, withdraw, print. I gave it to a classmate to try and within a minute the account was at −4000 €: all it took was withdrawing more than there was. In the second minute he had written account.balance = 'loads' and the program blew up on the next deposit. My class stored data, but it didn’t protect it. Today we fix that.

In the Python classes theory you saw __init__, self, class attributes, encapsulation with @property and magic methods. Now we use them in three complete, medium-level programs: a bank account that evolves from “anyone can touch it” to “nobody can break it”, the faculty library with books that are lent and returned, and the faculty copy shop, where a single VAT change affects every product at once. The data has traps on purpose (impossible withdrawals, years in the future, empty titles), because a well-made class shows its worth exactly when someone tries to misuse it.

Before you start

Make sure you are clear on:

  • That self.x = x in __init__ is what stores the value in the object.
  • How a property is written: @property to read and @name.setter to change with validation.
  • The difference between a class attribute (one for all) and an instance attribute (one per object).
  • That __str__ is what print shows and __repr__, what you see inside a list.

If any of these feels shaky, review the theory before going on.

In all three programs the errors are triggered on purpose at the end, inside a loop with try, so you see each one’s real message. exec(order) runs each line written as text: it’s just a convenient way to show several exceptions in a row, not something you’ll need in your programs.

Python classes practice — Program 1: Bank account (step by step evolution)

An account with a holder and a balance that allows deposits and withdrawals. What makes it tricky is that the balance can never end up in an absurd state: not negative, not with text inside, not with fake entries in the history. We’ll see it in two versions: the naive one (mine) and the encapsulated one.

The idea

In the first version everything is public and nothing is checked. In the second, the balance lives in self.__balance and can only be touched through three doors: the setter (which validates type and sign), deposit and withdraw (which validate the amount and that there are funds). The holder is read-only and the history is handed out as a copy.

Before programming, write down every way of leaving the account in an impossible state. Each one has to end in a raise.

The code

First, the naive version and what happened to it:

class BankAccount:
    def __init__(self, holder, initial_balance=0):
        self.holder = holder
        self.balance = initial_balance     # public attribute: anyone can change it

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        self.balance -= amount             # does not check whether there is money

    def __str__(self):
        return f'Account of {self.holder}: {self.balance:.2f} €'


account = BankAccount('Laura', 1000)
account.withdraw(5000)                     # goes negative without complaining
print(account)
account.balance = 'loads'                  # and now it is not even a number
print(account.balance)
account.deposit(50)

Output:

Account of Laura: -4000.00 €
mucho
Traceback (most recent call last):
  File "account_v1.py", line 21, in <module>
    account.deposit(50)
    ~~~~~~~~~~~~~~~^^^^
  File "account_v1.py", line 7, in deposit
    self.balance += amount
TypeError: can only concatenate str (not "int") to str

1000 − 5000 = −4000: the account goes negative without complaining. Then account.balance = 'loads' puts text where a number should be, and the error doesn’t appear there but on the next deposit, with a TypeError that has nothing to do with the real problem. That’s the worst that can happen: the failure shows up far from where it was caused.

And now the encapsulated version:

class BankAccount:
    _total_accounts = 0                    # class attribute: how many accounts there are

    def __init__(self, holder, initial_balance=0):
        if not isinstance(holder, str) or not holder.strip():
            raise ValueError('the holder must be a non-empty text')
        self.__holder = holder.strip()
        self.__transactions = []
        self.balance = initial_balance     # goes through the setter: validates on creation too
        BankAccount._total_accounts += 1

    @property
    def holder(self):                      # read-only: no setter
        return self.__holder

    @property
    def balance(self):
        return self.__balance

    @balance.setter
    def balance(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError('the balance must be a number')
        if value < 0:
            raise ValueError('the balance cannot be negative')
        self.__balance = value

    @property
    def transactions(self):
        return list(self.__transactions)   # copy: the real list never leaves

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError('the deposit amount must be positive')
        self.__balance += amount
        self.__transactions.append(f'+{amount:.2f}')

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError('the withdrawal amount must be positive')
        if amount > self.__balance:
            raise ValueError(f'insufficient funds: you have {self.__balance:.2f} €')
        self.__balance -= amount
        self.__transactions.append(f'-{amount:.2f}')

    @classmethod
    def total_accounts(cls):
        return cls._total_accounts

    def __str__(self):
        return f'Account of {self.__holder}: {self.__balance:.2f} €'

    def __repr__(self):
        return f'BankAccount({self.__holder!r}, {self.__balance})'


c1 = BankAccount('Laura', 1000)
c2 = BankAccount('Pablo', 500)
c1.deposit(250)
c1.withdraw(100)
print(c1)
print(c1.transactions)
history = c1.transactions
history.append('+1000000.00')              # trying to sneak in a transaction
print(c1.transactions)                     # it did not get in: it was a copy
print(repr(c2), '| accounts:', BankAccount.total_accounts())
for order in ['c1.withdraw(5000)', 'c1.balance = -100', "c1.balance = 'loads'", "c1.holder = 'Pablo'"]:
    try:
        exec(order)
    except (ValueError, TypeError, AttributeError) as err:
        print(f'{order:<20} {type(err).__name__}: {err}')

Zoom in on the heart of the class, the balance property:

@property
def balance(self):
    return self.__balance

@balance.setter
def balance(self, value):
    if not isinstance(value, (int, float)):
        raise TypeError('the balance must be a number')
    if value < 0:
        raise ValueError('the balance cannot be negative')
    self.__balance = value

The getter just returns self.__balance. The setter checks two things, each with its own exception: the type (TypeError if it isn’t a number) and the sign (ValueError if it is negative). So yes: setting the balance to a negative value through the setter raises ValueError. In __init__, self.balance = initial_balance goes through the setter, so you can’t even create an account starting at −50 €.

Output

Account of Laura: 1150.00 €
['+250.00', '-100.00']
['+250.00', '-100.00']
BankAccount('Pablo', 500) | accounts: 2
c1.withdraw(5000)    ValueError: insufficient funds: you have 1150.00 €
c1.balance = -100    ValueError: the balance cannot be negative
c1.balance = 'loads' TypeError: the balance must be a number
c1.holder = 'Pablo'  AttributeError: property 'holder' of 'BankAccount' object has no setter

Check: 1000 + 250 − 100 = 1150. The history stores +250.00 and -100.00. The attempt to sneak in a one-million entry doesn’t get in: history is a copy. And the four attacks at the end each end in their exception: no funds, the balance can’t be negative, nor a text, and the holder has no setter.

Returning a copyThe account object keeps its transaction list; the property returns a copy, which history points to, and the million append lands in the copy.history = c1.transactions → a copyBankAccount object (c1)__balance1150__transactions['+250.00', '-100.00'][…, '+1000000.00']historythe append lands in the copylist(…) makes the copy. If the property returned self.__transactions, the fake transaction would sneak into the inner list.
Two different lists: nobody outside can touch the inner one.

The drawing shows why the million didn’t get in: c1.transactions returns list(self.__transactions), a new list with the same data. The append lands in that copy and the inner one never notices.

Questions you might ask

Why do deposit and withdraw use self.__balance and not the setter? Because they already validate what matters to them (positive amount, enough funds) and the result can never be negative. Going through the setter would repeat the check. The setter is the door for whoever assigns the balance directly.

Why doesn’t the holder have a setter? Because in a real account the holder doesn’t change with an assignment. Without a setter, c1.holder = 'Pablo' gives AttributeError and Python’s message explains it: property 'holder' … has no setter.

Why TypeError for text and ValueError for a negative? As we saw in exceptions: text is the wrong type; −100 is the right type with a value that isn’t allowed. Using the precise exception lets whoever uses the class handle them separately.

What if someone writes c1._BankAccount__balance = -999? It works: name mangling isn’t a lock. But they have to do it on purpose, writing a weird name that shouts “don’t touch”. Encapsulation in Python protects against mistakes, not against someone who wants to get round it.

Writing return self.__transactions in the property. It works in all your tests… until someone does c1.transactions.append(...) and changes the real history without going through deposit.

Try it yourself

  • Add a transfer(self, other, amount) method that withdraws from this account and deposits into other, raising TypeError if other isn’t a BankAccount.
  • Add an overdraft limit as a class attribute (OVERDRAFT = 100) and let withdraw go down to −100 €, but no further.

Python classes practice — Program 2: Book library

The faculty library catalogue: a Book class with title, author, year and ISBN, which is lent and returned, and a Library class that stores books and lets you search them. What makes it tricky is that two classes work together and the data must always be validated: an empty title or a book published in 2030 can’t get in.

The idea

Book validates in its setters (non-empty title and author, a year between 1450, roughly the printing press, and the current year), has a read-only ISBN and controls its own state with lend and give_back, which raise ValueError if the operation makes no sense. Library doesn’t copy books: it stores references to the Book objects in a private list, and checks with isinstance that only books get in.

Before programming, decide who is responsible for what. Who knows whether a book is on loan? The book itself. Who knows which books there are? The library.

The code

CURRENT_YEAR = 2026


class Book:
    _total_books = 0

    def __init__(self, title, author, year, isbn):
        self.title = title           # setters: they validate from the start
        self.author = author
        self.year = year
        self.__isbn = isbn           # no setter: the ISBN does not change
        self.__available = True
        Book._total_books += 1

    @property
    def title(self):
        return self.__title

    @title.setter
    def title(self, value):
        if not isinstance(value, str) or not value.strip():
            raise ValueError('the title must be a non-empty text')
        self.__title = value.strip()

    @property
    def author(self):
        return self.__author

    @author.setter
    def author(self, value):
        if not isinstance(value, str) or not value.strip():
            raise ValueError('the author must be a non-empty text')
        self.__author = value.strip()

    @property
    def year(self):
        return self.__year

    @year.setter
    def year(self, value):
        if not isinstance(value, int) or not 1450 <= value <= CURRENT_YEAR:
            raise ValueError(f'invalid year: {value}')
        self.__year = value

    @property
    def isbn(self):
        return self.__isbn

    @property
    def available(self):
        return self.__available

    def lend(self):
        if not self.__available:
            raise ValueError(f'«{self.__title}» is already on loan')
        self.__available = False

    def give_back(self):
        if self.__available:
            raise ValueError(f'«{self.__title}» was not on loan')
        self.__available = True

    @classmethod
    def total_books(cls):
        return cls._total_books

    def __str__(self):
        status = 'available' if self.__available else 'on loan'
        return f'«{self.__title}», {self.__author} ({self.__year}): {status}'

    def __repr__(self):
        return f'Book({self.__title!r}, {self.__author!r}, {self.__year}, {self.__isbn!r})'


class Library:
    def __init__(self, name):
        self.__name = name
        self.__books = []            # stores REFERENCES to Book objects

    def add(self, book):
        if not isinstance(book, Book):
            raise TypeError('only Book objects can be added')
        self.__books.append(book)

    def search(self, text):
        return [l for l in self.__books if text.lower() in l.title.lower()]

    def available_books(self):
        return [l for l in self.__books if l.available]

    def __str__(self):
        return f'Library {self.__name}: {len(self.__books)} books'


lib = Library('of the EII')
l1 = Book('Python for Everybody', 'Severance', 2016, '978-1-5303-3762-6')
l2 = Book('Data Structures in C', 'Joyanes', 2005, '978-84-481-4227-6')
l3 = Book('Calculus', 'Stewart', 2018, '978-607-526-549-8')
for l in [l1, l2, l3]:
    lib.add(l)
print(lib)
l1.lend()
print(l1)
for action in [l1.lend, l3.give_back]:
    try:
        action()
    except ValueError as err:
        print('Error:', err)
print('Search "c":', lib.search('c'))
print('Available:', [l.title for l in lib.available_books()])
for bad in [("''", 'Nobody', 2020), ('Future', 'Someone', 2030)]:
    try:
        Book(eval(bad[0]) if bad[0] == "''" else bad[0], bad[1], bad[2], '000')
    except ValueError as err:
        print('Not created:', err)
print('Books created:', Book.total_books())

The two methods that change the book’s state:

def lend(self):
    if not self.__available:
        raise ValueError(f'«{self.__title}» is already on loan')
    self.__available = False

def give_back(self):
    if self.__available:
        raise ValueError(f'«{self.__title}» was not on loan')
    self.__available = True

Each one first checks whether the operation is possible and, if not, raises ValueError with the title in the message. That way, the error loop at the end can handle l1.lend and l3.give_back the same way.

Output

Library of the EII: 3 books
«Python for Everybody», Severance (2016): on loan
Error: «Python for Everybody» is already on loan
Error: «Calculus» was not on loan
Search "c": [Book('Data Structures in C', 'Joyanes', 2005, '978-84-481-4227-6'), Book('Calculus', 'Stewart', 2018, '978-607-526-549-8')]
Available: ['Data Structures in C', 'Calculus']
Not created: the title must be a non-empty text
Not created: invalid year: 2030
Books created: 3

What each line comes from:

Output lineWhat produces it
Library of the EII: 3 booksLibrary.__str__ with len(self.__books)
«Python for Everybody»…: on loanl1.lend() and Book.__str__
is already on loanThe second l1.lend() in the loop
was not on loanl3.give_back(): Calculus was never lent
Search "c": [Book(...), Book(...)]A list: each book’s __repr__ shows
Available: [...]Titles only: __repr__ no longer needed
Not created: … (twice)The title and year setters on creation

Look at the search: “c” appears in “Data Structures in C” and in “Calculus” (thanks to lower() on both sides), but not in “Python for Everybody”. And the counter says 3: the two bad books raised the exception before reaching Book._total_books += 1, so they don’t count.

The library and its booksThe Library object has a list with three slots pointing to three Book objects; the variable l1 also points to the first, which is on loan.lib stores references to the books, not copiesLibrary__books012lib"Python for Everybody"on loan"Data Structures in C"available"Calculus"availablel1l1.lend() and lib.available() talk about the SAME object: that is why it no longer shows as available.
Lending through l1 shows up in the library because both paths reach the same book.

The drawing explains why “Python for Everybody” no longer shows as available: l1 and the first slot of the library’s list point to the same object. What you do through one path is seen through the other.

Questions you might ask

Why CURRENT_YEAR = 2026 and not the system date? So the program always gives the same output. In a real program you’d use datetime.date.today().year.

Why does search return books and not titles? Because whoever searches usually wants to do something with the book (lend it). If it returned titles, they’d have to look for the object again.

What if I add the same book twice? The list would hold two references to the same object. A good “Try it yourself” is preventing it by checking if book in self.__books.

Why does the ISBN have a getter but no setter? Because it identifies the edition: if it changes, it’s another book. The property without a setter makes it visible but immutable.

Checking in Library whether a book is on loan by peeking at one of its attributes (book._Book__available). Ask the book through its public door (book.available): if one day it changes how it stores the data, the library won’t notice.

Try it yourself

  • Add a lend(title) method to Library that finds the book by exact title and lends it, or raises ValueError if it doesn’t exist.
  • Make add raise ValueError if there is already a book with the same ISBN.

Python classes practice — Program 3: Product store

The faculty copy shop sells lecture notes, notebooks and USB sticks. Each product has a name, a price before tax and stock, and its final price includes VAT. What makes it tricky is that the tax is the same for everyone and can change (in the Canary Islands there is no VAT but IGIC, at 7 %): changing it product by product would be an accident waiting to happen.

The idea

The tax is a class attribute, _vat, and it is changed with a @classmethod that validates it. The price with VAT isn’t stored: it is a computed property obtained each time from the price and the current _vat. So when the tax changes, every final price changes on its own. apply_discount reuses the price setter to round and validate.

Before programming, think about which data belongs to each product (name, price, stock) and which belongs to all (the tax).

The code

class Product:
    _vat = 0.21                     # class attribute: the same VAT for all

    def __init__(self, name, price, stock=0):
        self.name = name
        self.price = price
        self.__stock = 0
        self.add_stock(stock)       # reuses the method's validation

    @property
    def name(self):
        return self.__name

    @name.setter
    def name(self, value):
        if not isinstance(value, str) or not value.strip():
            raise ValueError('the name must be a non-empty text')
        self.__name = value.strip()

    @property
    def price(self):
        return self.__price

    @price.setter
    def price(self, value):
        if not isinstance(value, (int, float)) or value < 0:
            raise ValueError('the price must be a non-negative number')
        self.__price = round(value, 2)

    @property
    def price_with_vat(self):       # computed property: not stored, calculated
        return round(self.__price * (1 + Product._vat), 2)

    @property
    def stock(self):
        return self.__stock

    def add_stock(self, amount):
        if not isinstance(amount, int) or amount < 0:
            raise ValueError('the quantity must be a non-negative integer')
        self.__stock += amount

    def sell(self, amount=1):
        if not isinstance(amount, int) or amount <= 0:
            raise ValueError('the quantity must be a positive integer')
        if amount > self.__stock:
            raise ValueError(f'insufficient stock: {self.__stock}')
        self.__stock -= amount
        return round(self.price_with_vat * amount, 2)

    def apply_discount(self, percentage):
        if not 0 < percentage < 100:
            raise ValueError('the discount must be between 0 and 100')
        self.price = self.__price * (1 - percentage / 100)   # goes through the setter

    @classmethod
    def change_vat(cls, new):
        if not 0 <= new <= 1:
            raise ValueError('VAT must be between 0 and 1')
        cls._vat = new

    def __str__(self):
        return f'{self.__name}: {self.__price:.2f} € (with VAT {self.price_with_vat:.2f} €), stock {self.__stock}'


print('=== FACULTY COPY SHOP ===')
notes = Product('FP2 lecture notes', 12.40, 30)
notebook = Product('A4 notebook', 3.50, 50)
usb = Product('USB stick 64 GB', 9.90, 4)
for p in [notes, notebook, usb]:
    print(p)
print('Selling 3 notebooks:', notebook.sell(3), '€')
usb.apply_discount(20)
print(usb)
Product.change_vat(0.07)            # the Canary Islands IGIC tax, for all at once
print([p.price_with_vat for p in [notes, notebook, usb]])
for order in ['usb.sell(10)', 'notes.price = -5', 'notebook.sell(1.5)', 'Product.change_vat(7)']:
    try:
        exec(order)
    except ValueError as err:
        print(f'{order:<24} {err}')

The pair that makes shared VAT work:

@property
def price_with_vat(self):          # computed property: not stored, calculated
    return round(self.__price * (1 + Product._vat), 2)

And the class method that changes it:

@classmethod
def change_vat(cls, new):
    if not 0 <= new <= 1:
        raise ValueError('VAT must be between 0 and 1')
    cls._vat = new

price_with_vat has neither a setter nor an attribute of its own: it reads Product._vat every time someone asks. And change_vat uses cls._vat = new, which changes the attribute of the class. If you wrote self._vat = new in a normal method, you’d create a new attribute in a single product (the trap from the theory).

Output

=== FACULTY COPY SHOP ===
FP2 lecture notes: 12.40 € (with VAT 15.00 €), stock 30
A4 notebook: 3.50 € (with VAT 4.23 €), stock 50
USB stick 64 GB: 9.90 € (with VAT 11.98 €), stock 4
Selling 3 notebooks: 12.69 €
USB stick 64 GB: 7.92 € (with VAT 9.58 €), stock 4
[13.27, 3.75, 8.47]
usb.sell(10)             insufficient stock: 4
notes.price = -5         the price must be a non-negative number
notebook.sell(1.5)       the quantity must be a positive integer
Product.change_vat(7)    VAT must be between 0 and 1

Let’s check the sums:

ProductPriceWith 21 % VATWith 7 % IGIC
FP2 lecture notes12.4012.40 × 1.21 = 15.004 → 15.0012.40 × 1.07 = 13.268 → 13.27
A4 notebook3.503.50 × 1.21 = 4.235 → 4.233.50 × 1.07 = 3.745 → 3.75
USB stick (−20 %)9.90 × 0.8 = 7.927.92 × 1.21 = 9.5832 → 9.587.92 × 1.07 = 8.4744 → 8.47

Selling 3 notebooks charges 3 × 4.23 = 12.69 € with VAT. Notice the two “odd” roundings: 4.235 gives 4.23 and 3.745 gives 3.75. It isn’t a bug in the class: decimals like 4.235 can’t be stored exactly in binary (it is really 4.23499999…), and round works with the stored number.

This is the lookup rule from the theory: _vat lives in the class and each product finds it when looking it up; that’s why a single Product.change_vat(0.07) changes all three final prices.

Questions you might ask

Why does sell return the amount with VAT and not price * amount? Because what the copy shop charges is the final price. The old article returned the price before tax, which is not what you pay.

Why does apply_discount write self.price = … instead of self.__price = …? To go through the setter, which rounds to two decimals and checks it isn’t negative. Reusing the validation is safer than repeating it.

Why does notebook.sell(1.5) fail if 1.5 is positive? Because you don’t sell one and a half notebooks: isinstance(amount, int) rejects it.

Why does _vat have an underscore? It’s “private by convention”: you can read it, but to change it there is change_vat, which validates. If anyone did Product._vat = 7, every price would be multiplied by 8.

Storing the price with VAT as an attribute in __init__ (self.__price_vat = price * 1.21). It works until the tax changes: the products already created keep the old price.

Try it yourself

  • Add a class attribute _products = [] (here sharing does make sense) where each product registers itself on creation, and a @classmethod that returns the total value of the stock.
  • Add __repr__ and check the difference when printing [notes, notebook].

Common mistakes in this practice

  • Public attributes for data that needs protecting: anyone can leave them in an impossible state and the error shows up far away.
  • Returning the inner list from a property: whoever receives it can modify the object without going through its methods.
  • Validating only in setters and forgetting __init__: assign in __init__ with self.x = x (not self.__x) to go through the validation.
  • Changing a class attribute through self.: it creates a new attribute in one object instead of changing it for all; use cls. or the class name.
  • Storing computed values that depend on others: better a computed property, which is never out of date.
  • Generic exceptions: TypeError for the type, ValueError for the value, and a message saying which.

Watch it step by step in Python Tutor

In Python Tutor (choose Python 3) you can clearly see program 1’s copy trick, and also how the mangled attributes appear:

class Account:
    def __init__(self, holder, balance):
        self.__holder = holder
        self.__transactions = []
        self.__balance = balance

    @property
    def transactions(self):
        return list(self.__transactions)

    def deposit(self, c):
        self.__balance += c
        self.__transactions.append(c)


c = Account('Laura', 100)
c.deposit(50)
copied = c.transactions
copied.append(999)
print(c.transactions, copied)

Output:

[50] [50, 999]

What to watch:

  1. When c is created, the object shows the attributes _Account__holder, _Account__transactions and _Account__balance: name mangling live.
  2. After c.deposit(50), the object’s transactions list contains 50.
  3. At copied = c.transactions, look at the arrows: copied points to a new list, different from the object’s.
  4. After copied.append(999), only copied‘s list changes. The output confirms it: [50] versus [50, 999].

Summary and next step

  • A public attribute without validation allows impossible states; a property with a setter stops them the moment they are attempted.
  • Assign in __init__ through the properties so an object can’t be born broken.
  • Return copies (list(...)) of anything mutable: the inner list is only touched by the methods.
  • Classes work together by storing references: the library and l1 see the same book.
  • What is the same for everyone (the tax) goes in a class attribute, changed with a @classmethod.
  • What can be calculated (the price with VAT) is better as a computed property than as stored data.

Checklist: have you practised it?

  • Can you explain why version 1 of the account gives the TypeError in deposit and not in the assignment?
  • Do you understand why the million didn’t get into the history?
  • Could you say why the book counter is 3 even though 5 were attempted?
  • Can you explain what would happen if change_vat used self instead of cls?
  • Have you run the three programs and checked the outputs?
  • Have you tried the “Try it yourself” tasks without looking at the theory?

If you answered yes to almost all of them, you are ready for the Python classes exercises, where I no longer give you the code. See you there!

Next step: exercisesPython classes exercises — a student and their grades, a vehicle that loses value and a stack with its own exceptions (and Laura’s report with Pablo’s fail on it)

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *