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.
Table of Contents
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.

One Comment