Python classes practice encapsulation property bank library store FP2

Python classes practice — 3 real programs with encapsulation and @property

In the previous article we covered Python classes theory. Now it’s time to write real programs. In this article we build three classes from scratch — a bank account, a book library and a product store. Each one introduces new concepts progressively and shows encapsulation and @property doing real work, not just theory.

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

This program shows how a class evolves — starting minimal and adding features one by one. The evolution itself is the lesson.

Version 1 — Minimal

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

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

    def withdraw(self, amount):
        self.balance -= amount

    def __str__(self):
        return f'{self.owner}: €{self.balance:.2f}'

account = BankAccount('Sergio', 1000)
account.deposit(500)
account.withdraw(200)
print(account)    # → Sergio: €1300.00

This works but has a problem: nothing prevents negative amounts or overdrafts. account.balance = -9999 works without error. account.withdraw(99999) empties the account beyond zero. Let’s fix that.

Version 2 — With validation

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance      # private — controlled through methods

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

    @balance.setter
    def balance(self, amount):
        if amount < 0:
            raise ValueError('Balance cannot be negative')
        self._balance = round(amount, 2)

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError(f'Deposit amount must be positive, got {amount}')
        self._balance = round(self._balance + amount, 2)
        return self._balance

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError(f'Withdrawal amount must be positive, got {amount}')
        if amount > self._balance:
            raise ValueError(
                f'Insufficient funds: balance €{self._balance:.2f}, '
                f'requested €{amount:.2f}'
            )
        self._balance = round(self._balance - amount, 2)
        return self._balance

    def __str__(self):
        return f'{self.owner}: €{self._balance:.2f}'

    def __repr__(self):
        return f"BankAccount(owner='{self.owner}', balance={self._balance})"

    def __bool__(self):
        return self._balance > 0

Version 3 — Complete with transaction history

from datetime import datetime

class BankAccount:
    DAILY_WITHDRAWAL_LIMIT = 1000.0

    def __init__(self, account_id, owner, balance=0):
        self.account_id = account_id
        self.owner = owner
        self._balance = balance
        self._transactions = []
        self._daily_withdrawn = 0

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

    @property
    def transaction_count(self):
        return len(self._transactions)

    def _record(self, type_, amount):
        self._transactions.append({
            'type': type_,
            'amount': amount,
            'balance': self._balance,
            'time': datetime.now().strftime('%H:%M:%S')
        })

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError(f'Deposit must be positive: {amount}')
        self._balance = round(self._balance + amount, 2)
        self._record('deposit', amount)
        return self._balance

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError(f'Withdrawal must be positive: {amount}')
        if self._daily_withdrawn + amount > self.DAILY_WITHDRAWAL_LIMIT:
            raise ValueError(
                f'Daily withdrawal limit exceeded: '
                f'limit €{self.DAILY_WITHDRAWAL_LIMIT:.2f}, '
                f'attempted €{self._daily_withdrawn + amount:.2f}'
            )
        if amount > self._balance:
            raise ValueError(
                f'Insufficient funds: balance €{self._balance:.2f}, '
                f'requested €{amount:.2f}'
            )
        self._balance = round(self._balance - amount, 2)
        self._daily_withdrawn = round(self._daily_withdrawn + amount, 2)
        self._record('withdrawal', amount)
        return self._balance

    def transfer(self, target, amount):
        self.withdraw(amount)
        target.deposit(amount)
        print(f'  ✓ Transfer €{amount:.2f}: {self.owner} → {target.owner}')

    def statement(self):
        print(f'\n--- Statement: {self.owner} ({self.account_id}) ---')
        print(f'Balance: €{self._balance:.2f} | '
              f'Transactions: {len(self._transactions)} | '
              f'Daily withdrawn: €{self._daily_withdrawn:.2f}')
        if self._transactions:
            print('History:')
            for t in self._transactions:
                symbol = '+' if t['type'] == 'deposit' else '-'
                print(f'  [{t["time"]}] {symbol}€{t["amount"]:.2f} '
                      f'→ €{t["balance"]:.2f}')

    def __str__(self):
        return f'{self.owner} ({self.account_id}): €{self._balance:.2f}'

    def __repr__(self):
        return (f"BankAccount(id='{self.account_id}', "
                f"owner='{self.owner}', balance={self._balance})")

    def __bool__(self):
        return self._balance > 0

    def __eq__(self, other):
        return isinstance(other, BankAccount) and self.account_id == other.account_id

    def __lt__(self, other):
        return self._balance < other._balance


# Usage
print('=== BANK ACCOUNT SYSTEM ===\n')

sergio = BankAccount('ACC001', 'Sergio', 1000)
maria  = BankAccount('ACC002', 'María', 500)

try:
    sergio.deposit(250)
    print(f'✓ Deposit €250 → {sergio}')

    sergio.withdraw(100)
    print(f'✓ Withdrawal €100 → {sergio}')

    sergio.transfer(maria, 300)
    print(f'✓ After transfer: {sergio}')
    print(f'✓ María after transfer: {maria}')

    # Test error handling
    try:
        sergio.withdraw(5000)
    except ValueError as err:
        print(f'✗ {err}')

    try:
        sergio.withdraw(900)    # already withdrew 400 today
    except ValueError as err:
        print(f'✗ {err}')

    # Sorting accounts by balance
    accounts = [sergio, maria]
    richest = max(accounts)
    print(f'\nHighest balance: {richest}')

    sergio.statement()
    maria.statement()

except ValueError as err:
    print(f'Error: {err}')

Output:

=== BANK ACCOUNT SYSTEM ===

✓ Deposit €250 → Sergio (ACC001): €1250.00
✓ Withdrawal €100 → Sergio (ACC001): €1150.00
  ✓ Transfer €300: Sergio → María
✓ After transfer: Sergio (ACC001): €850.00
✓ María after transfer: María (ACC002): €800.00
✗ Insufficient funds: balance €850.00, requested €5000.00
✗ Daily withdrawal limit exceeded: limit €1000.00, attempted €1400.00

Highest balance: Sergio (ACC001): €850.00

--- Statement: Sergio (ACC001) ---
Balance: €850.00 | Transactions: 3 | Daily withdrawn: €400.00
History:
  [HH:MM:SS] +€250.00 → €1250.00
  [HH:MM:SS] -€100.00 → €1150.00
  [HH:MM:SS] -€300.00 → €850.00

The three-version evolution shows the power of encapsulation — each version is a drop-in replacement for the previous one. Code that uses account.deposit(100) works the same across all three versions, but version 3 does much more under the hood. That’s what encapsulation buys you.

Python classes practice — Program 2: Book library

This program models a library with books that can be borrowed and returned. It introduces the concept of objects that contain collections of other objects.

class Book:
    def __init__(self, isbn, title, author, year, copies=1):
        self.isbn = isbn
        self.title = title
        self.author = author
        self.year = year
        self._total_copies = copies
        self._available = copies
        self._borrowers = []

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

    @property
    def is_available(self):
        return self._available > 0

    @property
    def total_copies(self):
        return self._total_copies

    def borrow(self, borrower_name):
        if not self.is_available:
            raise ValueError(
                f'"{self.title}" is not available '
                f'({self._total_copies} copies, all borrowed)'
            )
        self._available -= 1
        self._borrowers.append(borrower_name)

    def return_book(self, borrower_name):
        if borrower_name not in self._borrowers:
            raise ValueError(
                f'{borrower_name} has not borrowed "{self.title}"'
            )
        self._available += 1
        self._borrowers.remove(borrower_name)

    def __str__(self):
        status = f'({self._available}/{self._total_copies} available)'
        return f'"{self.title}" by {self.author} ({self.year}) {status}'

    def __repr__(self):
        return (f"Book(isbn='{self.isbn}', title='{self.title}', "
                f"author='{self.author}')")

    def __eq__(self, other):
        return isinstance(other, Book) and self.isbn == other.isbn

    def __lt__(self, other):
        return self.title.lower() < other.title.lower()


class Library:
    def __init__(self, name):
        self.name = name
        self._catalogue = {}    # isbn → Book

    def add_book(self, book):
        if book.isbn in self._catalogue:
            raise ValueError(f'Book already in catalogue: ISBN {book.isbn}')
        self._catalogue[book.isbn] = book
        print(f'  ✓ Added: {book}')

    def find_by_title(self, query):
        query = query.lower()
        results = [b for b in self._catalogue.values()
                   if query in b.title.lower()]
        if not results:
            raise ValueError(f'No books found matching: "{query}"')
        return sorted(results)

    def find_by_author(self, author):
        author = author.lower()
        results = [b for b in self._catalogue.values()
                   if author in b.author.lower()]
        if not results:
            raise ValueError(f'No books found by: "{author}"')
        return sorted(results)

    def borrow(self, isbn, borrower):
        if isbn not in self._catalogue:
            raise ValueError(f'ISBN not found: {isbn}')
        self._catalogue[isbn].borrow(borrower)
        print(f'  ✓ {borrower} borrowed: "{self._catalogue[isbn].title}"')

    def return_book(self, isbn, borrower):
        if isbn not in self._catalogue:
            raise ValueError(f'ISBN not found: {isbn}')
        self._catalogue[isbn].return_book(borrower)
        print(f'  ✓ {borrower} returned: "{self._catalogue[isbn].title}"')

    def catalogue(self):
        if not self._catalogue:
            print('  Library is empty')
            return
        print(f'\n--- {self.name} Catalogue ---')
        for book in sorted(self._catalogue.values()):
            print(f'  {book}')

    @property
    def total_books(self):
        return len(self._catalogue)

    @property
    def available_books(self):
        return sum(1 for b in self._catalogue.values() if b.is_available)

    def __str__(self):
        return (f'{self.name}: {self.total_books} titles, '
                f'{self.available_books} available')

    def __len__(self):
        return self.total_books


# Usage
print('=== LIBRARY SYSTEM ===\n')

library = Library('Sergio Learns Library')

# Add books
library.add_book(Book('978-0-7432-7356-5', 'The Pragmatic Programmer',
                      'Andrew Hunt', 2019, 2))
library.add_book(Book('978-0-13-468599-1', 'Clean Code',
                      'Robert C. Martin', 2008, 1))
library.add_book(Book('978-0-13-235088-4', 'The Clean Coder',
                      'Robert C. Martin', 2011, 3))

print(f'\n{library}')
library.catalogue()

# Borrow books
print('\n--- Borrowing ---')
try:
    library.borrow('978-0-7432-7356-5', 'Sergio')
    library.borrow('978-0-7432-7356-5', 'María')
    library.borrow('978-0-7432-7356-5', 'Carlos')    # only 2 copies
except ValueError as err:
    print(f'  ✗ {err}')

# Search
print('\n--- Search ---')
try:
    results = library.find_by_author('martin')
    print(f'Books by "Martin":')
    for book in results:
        print(f'  {book}')
except ValueError as err:
    print(f'  ✗ {err}')

# Return
print('\n--- Returning ---')
library.return_book('978-0-7432-7356-5', 'Sergio')

library.catalogue()

Output:

=== LIBRARY SYSTEM ===

  ✓ Added: "The Pragmatic Programmer" by Andrew Hunt (2019) (2/2 available)
  ✓ Added: "Clean Code" by Robert C. Martin (2008) (1/1 available)
  ✓ Added: "The Clean Coder" by Robert C. Martin (2011) (3/3 available)

Sergio Learns Library: 3 titles, 3 available

--- Borrowing ---
  ✓ Sergio borrowed: "The Pragmatic Programmer"
  ✓ María borrowed: "The Pragmatic Programmer"
  ✗ "The Pragmatic Programmer" is not available (2 copies, all borrowed)

--- Search ---
Books by "Martin":
  "Clean Code" by Robert C. Martin (2008) (1/1 available)
  "The Clean Coder" by Robert C. Martin (2011) (3/3 available)

--- Returning ---
  ✓ Sergio returned: "The Pragmatic Programmer"

--- Sergio Learns Library Catalogue ---
  "Clean Code" by Robert C. Martin (2008) (1/1 available)
  "The Clean Coder" by Robert C. Martin (2011) (3/3 available)
  "The Pragmatic Programmer" by Andrew Hunt (2019) (1/2 available)

Python classes practice — Program 3: Product store

This program models a store with products and a shopping cart — the most complete of the three, combining two classes that work together.

class Product:
    vat_rate = 0.21

    def __init__(self, code, name, price, stock=0, category='General'):
        self.code = code
        self.name = name
        self._price = price
        self._stock = stock
        self.category = category

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

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError(f'Price cannot be negative: {value}')
        self._price = round(value, 2)

    @property
    def price_with_vat(self):
        return round(self._price * (1 + self.vat_rate), 2)

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

    @property
    def in_stock(self):
        return self._stock > 0

    def add_stock(self, quantity):
        if quantity <= 0:
            raise ValueError(f'Quantity must be positive: {quantity}')
        self._stock += quantity

    def reserve(self, quantity):
        if quantity <= 0:
            raise ValueError(f'Quantity must be positive: {quantity}')
        if quantity > self._stock:
            raise ValueError(
                f'Insufficient stock for "{self.name}": '
                f'{self._stock} available, {quantity} requested'
            )
        self._stock -= quantity

    def __str__(self):
        availability = f'{self._stock} in stock' if self.in_stock else 'OUT OF STOCK'
        return f'[{self.code}] {self.name} — €{self._price:.2f} ({availability})'

    def __repr__(self):
        return (f"Product(code='{self.code}', name='{self.name}', "
                f"price={self._price}, stock={self._stock})")

    def __bool__(self):
        return self.in_stock

    def __eq__(self, other):
        return isinstance(other, Product) and self.code == other.code

    def __lt__(self, other):
        return self._price < other._price


class ShoppingCart:
    def __init__(self, customer_name):
        self.customer = customer_name
        self._items = {}    # product_code → {'product': Product, 'qty': int}
        self._discount = 0

    @property
    def item_count(self):
        return sum(item['qty'] for item in self._items.values())

    @property
    def subtotal(self):
        return round(sum(
            item['product'].price * item['qty']
            for item in self._items.values()
        ), 2)

    @property
    def discount_amount(self):
        return round(self.subtotal * self._discount / 100, 2)

    @property
    def total(self):
        after_discount = self.subtotal - self.discount_amount
        return round(after_discount * (1 + Product.vat_rate), 2)

    @property
    def discount(self):
        return self._discount

    @discount.setter
    def discount(self, pct):
        if not 0 <= pct <= 100:
            raise ValueError(f'Discount must be 0-100%, got {pct}%')
        self._discount = pct

    def add(self, product, quantity=1):
        if quantity <= 0:
            raise ValueError(f'Quantity must be positive: {quantity}')
        if not product.in_stock:
            raise ValueError(f'"{product.name}" is out of stock')
        if product.stock < quantity:
            raise ValueError(
                f'Only {product.stock} "{product.name}" available, '
                f'requested {quantity}'
            )
        product.reserve(quantity)
        if product.code in self._items:
            self._items[product.code]['qty'] += quantity
        else:
            self._items[product.code] = {'product': product, 'qty': quantity}
        print(f'  ✓ Added {quantity}x {product.name} → €{product.price * quantity:.2f}')

    def remove(self, product_code, quantity=None):
        if product_code not in self._items:
            raise ValueError(f'Product not in cart: {product_code}')
        item = self._items[product_code]
        if quantity is None or quantity >= item['qty']:
            # Return all stock
            item['product'].add_stock(item['qty'])
            del self._items[product_code]
            print(f'  ✓ Removed {item["product"].name} from cart')
        else:
            item['qty'] -= quantity
            item['product'].add_stock(quantity)
            print(f'  ✓ Reduced {item["product"].name} by {quantity}')

    def receipt(self):
        print(f'\n=== RECEIPT — {self.customer} ===')
        if not self._items:
            print('  Cart is empty')
            return

        print(f'{"Product":<25} {"Qty":>4} {"Unit":>8} {"Line":>9}')
        print('-' * 50)

        for item in self._items.values():
            p = item['product']
            line = p.price * item['qty']
            print(f'{p.name:<25} {item["qty"]:>4} '
                  f'€{p.price:>6.2f} €{line:>8.2f}')

        print('-' * 50)
        print(f'{"Subtotal:":>40} €{self.subtotal:>8.2f}')
        if self._discount > 0:
            print(f'{"Discount (" + str(self._discount) + "%):":>40} '
                  f'-€{self.discount_amount:>7.2f}')
        print(f'{"VAT (21%):":>40} €{self.total - self.subtotal + self.discount_amount:>8.2f}')
        print(f'{"TOTAL:":>40} €{self.total:>8.2f}')
        print(f'\n{self.item_count} item(s)')

    def __str__(self):
        return (f'{self.customer}\'s cart: '
                f'{self.item_count} items, €{self.subtotal:.2f}')

    def __len__(self):
        return self.item_count

    def __bool__(self):
        return bool(self._items)


# Usage
print('=== PRODUCT STORE ===\n')

# Create products
laptop   = Product('LAP001', 'Laptop Pro 15',    999.99, 10, 'Electronics')
mouse    = Product('MOU001', 'Wireless Mouse',    29.99,  50, 'Electronics')
keyboard = Product('KEY001', 'Mechanical Keyboard', 79.99, 20, 'Electronics')
notebook = Product('NOT001', 'A5 Notebook',        4.99, 100, 'Stationery')

products = [laptop, mouse, keyboard, notebook]

print('--- Catalogue ---')
for p in sorted(products):
    print(f'  {p}')

# Shopping
print('\n--- Shopping ---')
cart = ShoppingCart('Sergio')

try:
    cart.add(laptop, 1)
    cart.add(mouse, 2)
    cart.add(notebook, 3)
    cart.discount = 10    # 10% discount

    print(f'\nCart: {cart}')

    cart.remove('NOT001', 1)   # remove 1 notebook

    # Test error
    try:
        cart.add(laptop, 100)   # only 9 left
    except ValueError as err:
        print(f'  ✗ {err}')

    cart.receipt()

except ValueError as err:
    print(f'Error: {err}')

Output:

=== PRODUCT STORE ===

--- Catalogue ---
  [NOT001] A5 Notebook — €4.99 (100 in stock)
  [MOU001] Wireless Mouse — €29.99 (50 in stock)
  [KEY001] Mechanical Keyboard — €79.99 (20 in stock)
  [LAP001] Laptop Pro 15 — €999.99 (10 in stock)

--- Shopping ---
  ✓ Added 1x Laptop Pro 15 → €999.99
  ✓ Added 2x Wireless Mouse → €59.98
  ✓ Added 3x A5 Notebook → €14.97
  ✗ Only 9 "Laptop Pro 15" available, requested 100
  ✓ Reduced A5 Notebook by 1

=== RECEIPT — Sergio ===
Product                    Qty     Unit      Line
--------------------------------------------------
Laptop Pro 15                1  €999.99  €999.99
Wireless Mouse               2   €29.99   €59.98
A5 Notebook                  2    €4.99    €9.98
--------------------------------------------------
                     Subtotal: €1069.95
                 Discount (10%): -€106.99
                     VAT (21%):  €197.40
                        TOTAL: €1160.36

4 item(s)

Visualise with Python Tutor

Copy this code into pythontutor.com and step through it:

class Product:
    vat_rate = 0.21    # class attribute

    def __init__(self, name, price):
        self.name = name
        self._price = price    # private

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

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError('Negative price')
        self._price = round(value, 2)

    @property
    def price_with_vat(self):
        return round(self._price * (1 + self.vat_rate), 2)

    def __str__(self):
        return f'{self.name}: €{self._price}'

p = Product('Laptop', 999.99)
print(p)
print(p.price_with_vat)

p.price = 849.99
print(p)

try:
    p.price = -100
except ValueError as err:
    print(err)

Step through and observe three key moments. When p = Product('Laptop', 999.99) runs, __init__ is called with self bound to the new object — Python Tutor shows self._price = 999.99 being created on that object. When p.price is accessed, Python calls the @property getter method — it looks like attribute access but is actually a function call. When p.price = 849.99 is assigned, Python calls the @price.setter method — another function call disguised as assignment. This is what @property buys you: function call behaviour with attribute syntax.

Summary and next step

In this article you practised Python classes with three real programs built in layers. You used @property for controlled attribute access with validation, __str__ and __repr__ for readable output, __bool__, __len__, __eq__ and __lt__ for natural Python behaviour, private attributes with _ convention, class attributes shared across instances, and objects that contain collections of other objects.

In the next article you’ll find exercises to solve on your own.

Similar Posts

One Comment

Leave a Reply

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