DEV Community

Cover image for Week 5: Why Composition Beats Inheritance.
Om Kolhapure
Om Kolhapure

Posted on

Week 5: Why Composition Beats Inheritance.

What I Learned in Week 5 of Python — OOP Deep Dive

Week 4 ended with me putting functions inside a class for the first time.

Week 5 started with me realising that was barely scratching the surface.

OOP isn't a feature of Python — it's a completely different way of thinking about programs. Instead of asking "what does this code do?" you start asking "what things exist in this system, and what can they do?" One week later I've built a bank account system, two subclass hierarchies, a library with composition, and a full task management system using OOP throughout.

Here's how it went.


Day 29 — Classes & Objects: Building a Bank Account

The first real class I built from scratch. A BankAccount that holds state, tracks history, and enforces rules — all inside one object.

class BankAccount:
    def __init__(self, name, balance):
        self.name = name
        self.balance = balance
        self.history = [f"Account created with account name: {self.name} and account balance: {self.balance}"]

    def __str__(self):
        return f"BankAccount({self.name}, {self.balance})"

    def deposit(self, deposit_amount):
        self.balance += deposit_amount
        self.history.append(
            f"Amount {deposit_amount} deposited in {self.name} account. Total Balance = ${self.balance}"
        )
        return self.balance

    def withdraw(self, withdrawal_amount):
        if self.balance >= withdrawal_amount:
            self.balance -= withdrawal_amount
            self.history.append(
                f"Amount {withdrawal_amount} withdrawn from {self.name} account. Total Balance = ${self.balance}"
            )
            return self.balance
        else:
            self.history.append(f"Failed to withdraw ${withdrawal_amount}; insufficient funds")
            return self.balance

    def transfer(self, amount, recipient):
        if isinstance(recipient, BankAccount):
            if self.balance >= amount:
                self.balance -= amount
                recipient.balance += amount
                self.history.append(f"Transferred ${amount} to {recipient.name}")
                recipient.history.append(f"Received ${amount} from {self.name}")
                return self.balance
            else:
                self.history.append("Insufficient funds, failed to transfer")
                return self.balance
        else:
            print("Not a valid bank account!")
            return self.balance

    def get_balance(self):
        return self.balance

    def get_history(self):
        return self.history

    # Getter and setter for balance
    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, balance):
        try:
            self._balance = float(balance)
        except ValueError:
            print("Enter numbers")
            self._balance = 0.0

    # Getter and setter for name
    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, name):
        if not name:
            print("Missing name!")
            self._name = "Unnamed"
        else:
            self._name = name


def main():
    # Create accounts
    alice = BankAccount("Alice", 100)
    bob = BankAccount("Bob", 50)

    # Test basic operations
    alice.deposit(25)
    alice.withdraw(10)
    alice.withdraw(200)  # Should fail

    # Test transfer
    alice.transfer(30, bob)

    # Inspect results
    print("Alice balance:", alice.get_balance())   # Expected: 85
    print("Bob balance:", bob.get_balance())       # Expected: 80

    print("\nAlice history:")
    for line in alice.get_history():
        print(line)

    print("\nBob history:")
    for line in bob.get_history():
        print(line)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Three things that landed hard on Day 29:

  • __init__ is not a constructor in the traditional sense — it's an initialiser. The object already exists when __init__ runs. It just sets the starting state.
  • self is just the object referring to itself. Once that clicked, method signatures stopped looking strange.
  • isinstance(recipient, BankAccount) in transfer() — type-checking before operating on another object. That's the class enforcing its own rules, not the caller's responsibility.

The history list on each account was my favourite part of this design. Every operation appends a human-readable log entry. By the end of a session, calling get_history() shows the complete life of that account. That feels genuinely useful.


Day 30 — Inheritance: CheckingAccount and SavingsAccount

If Day 29 was "what is a class," Day 30 was "what happens when classes are related."

CheckingAccount and SavingsAccount both inherit from BankAccount — they get everything the parent has, and add their own rules on top. CheckingAccount allows overdrafts (with a $35 fee). SavingsAccount earns interest.

from bank_account import BankAccount

class CheckingAccount(BankAccount):
    def __init__(self, name, balance, overdraft_limit = 16):
        super().__init__(name, balance)
        self.overdraft_limit = overdraft_limit

    def withdraw(self, amount):
        if self.balance >= amount:
            self.balance -= amount
            self.history.append(f"Amount {amount} withdrawn from {self.name} account. Total Balance = ${self.balance}")
            return self.balance
        else:
            if self.balance - amount -35 < -self.overdraft_limit:
                self.history.append(f"Withdrawal rejected! exceeded the overdraft limit")
                return self.balance
            self.balance -= (amount + 35)
            self.history.append(f"Overdraft: Withdrew ${amount} with $35 fee. New balance: ${self.balance}")
            return self.balance

    def transfer(self, amount, recipient):
        if isinstance(recipient, BankAccount):
            if self.balance >= amount:
                self.balance -= amount
                recipient.balance += amount
                self.history.append(f"Transferred ${amount} to {recipient.name}")
                recipient.history.append(f"Received ${amount} from {self.name}")
                return self.balance
            self.balance -= (amount + 35)
            self.history.append(f"Overdraft: Tansferred ${amount} with $35 fee to {recipient.name}. New balance: ${self.balance}")
            recipient.balance += amount
            self.history.append(f"Received ${amount} from {self.name}")
            return self.balance
        else:
            print("Not a valid bank account!")
            return self.balance

class SavingsAccount(BankAccount):
    def __init__(self, name, balance, intrest_rate = 0.02):
        super().__init__(name, balance)
        self.intrest_rate = intrest_rate

    def apply_intrest(self):
        intrest = self.balance * self.intrest_rate
        self.balance += intrest
        self.history.append(f"Interest applied: ${intrest}. New balance: ${self.balance}")
        return self.balance


def main():

    # --- Checking Account Tests ---
    print("=== Checking Account ===")
    checking = CheckingAccount("Alice", 100, overdraft_limit=100)

    checking.withdraw(50)      # Normal: balance 50
    checking.withdraw(80)      # Overdraft: balance -65 (80 + 35 fee)
    checking.withdraw(100)     # Exceeds overdraft limit: should fail, balance stays -65
    checking.deposit(20)       # Balance -45

    print("Checking balance:", checking.get_balance())  # Expected: -45
    print("Checking history:")
    for line in checking.get_history():
        print(line)

    # --- Savings Account Tests ---
    print("\n=== Savings Account ===")
    savings = SavingsAccount("Bob", 1000, intrest_rate=0.05)
    savings.apply_intrest()     # Balance 1050
    savings.withdraw(2000)       # Should fail (no overdraft)
    savings.withdraw(50)         # Balance 1000

    print("Savings balance:", savings.get_balance())  # Expected: 1000
    print("Savings history:")
    for line in savings.get_history():
        print(line)

    # --- Cross-account Transfer ---
    print("\n=== Cross-Account Transfer ===")
    checking2 = CheckingAccount("Charlie", 50, overdraft_limit=100)
    savings2 = SavingsAccount("Dana", 200, intrest_rate=0.02)

    checking2.transfer(75, savings2)   # Charlie overdrafts by 25 + 35 fee. Dana receives 75.
    print("Charlie balance:", checking2.get_balance())   # Expected: -60
    print("Dana balance:", savings2.get_balance())       # Expected: 275

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Two things that clicked:

  • super().__init__(name, balance) — calling the parent class's __init__ from the child. Without this, the inherited attributes and history list wouldn't exist. super() is the clean way to say "do everything the parent does, then do my extra stuff."
  • Method overridingCheckingAccount defines its own withdraw() that replaces the parent's version. It doesn't break any existing code — objects that were BankAccount instances still work. Subclasses can specialise without touching the parent.

The cross-account transfer test was the most satisfying: Charlie (a CheckingAccount) transfers more than his balance to Dana (a SavingsAccount). The overdraft fires, adds the fee, and both histories log the event accurately. Two different account types, one shared transaction method.


Day 31 — Encapsulation & Properties

Encapsulation is the idea that an object's internal data should be accessed through the object, not grabbed directly. Python doesn't enforce this by convention the way some languages do — but @property gives you the tools to control it.

The @property decorators were already in bank_account.py from Day 29. Day 31 was understanding why they're there:

    # Getter and setter for balance
    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, balance):
        try:
            self._balance = float(balance)
        except ValueError:
            print("Enter numbers")
            self._balance = 0.0

    # Getter and setter for name
    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, name):
        if not name:
            print("Missing name!")
            self._name = "Unnamed"
        else:
            self._name = name
Enter fullscreen mode Exit fullscreen mode

The _balance and _name attributes are private by convention — the underscore is a signal to other developers not to access them directly. The @property getter lets code read alice.balance as normal. The @balance.setter intercepts any attempt to write alice.balance = value and validates it first.

The result: you can't accidentally set a balance to a string. The setter catches it with try/except ValueError, resets to 0.0, and the account keeps running. The object protects its own data.


Day 32 — Dunder Methods: Making Objects Feel Native

Dunder (double-underscore) methods are how you teach Python's built-in operators what to do with your custom objects. __str__, __repr__, __eq__ — these make your class feel like a native Python type.

class BankAccount:
    def __init__(self, name, balance):
        self.name = name
        self.balance = balance
        self.history = [f"Account created with account name: {self.name} and account balance: {self.balance}"]

    def __str__(self):
        return f"BankAccount({self.name}, {self.balance})"

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

    def __eq__(self, other):
            if not isinstance(other, BankAccount):
                print("NotImplemented!")
            return self.name == other.name and self.balance == other.balance

    def deposit(self, deposit_amount):
        self.balance += deposit_amount
        self.history.append(
            f"Amount {deposit_amount} deposited in {self.name} account. Total Balance = ${self.balance}"
        )
        return self.balance

    def withdraw(self, withdrawal_amount):
        if self.balance >= withdrawal_amount:
            self.balance -= withdrawal_amount
            self.history.append(
                f"Amount {withdrawal_amount} withdrawn from {self.name} account. Total Balance = ${self.balance}"
            )
            return self.balance
        else:
            self.history.append(f"Failed to withdraw ${withdrawal_amount}; insufficient funds")
            return self.balance

    def transfer(self, amount, recipient):
        if isinstance(recipient, BankAccount):
            if self.balance >= amount:
                self.balance -= amount
                recipient.balance += amount
                self.history.append(f"Transferred ${amount} to {recipient.name}")
                recipient.history.append(f"Received ${amount} from {self.name}")
                return self.balance
            else:
                self.history.append("Insufficient funds, failed to transfer")
                return self.balance
        else:
            print("Not a valid bank account!")
            return self.balance

    def get_balance(self):
        return self.balance

    def get_history(self):
        return self.history

    # Getter and setter for balance
    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, balance):
        try:
            self._balance = float(balance)
        except ValueError:
            print("Enter numbers")
            self._balance = 0.0

    # Getter and setter for name
    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, name):
        if not name:
            print("Missing name!")
            self._name = "Unnamed"
        else:
            self._name = name


def main():
    # Create accounts
    alice = BankAccount("Alice", 100)
    bob = BankAccount("Bob", 50)

    # Test basic operations
    alice.deposit(25)
    alice.withdraw(10)
    alice.withdraw(200)  # Should fail

    # Test transfer
    alice.transfer(30, bob)

    # Inspect results
    print("Alice balance:", alice.get_balance())   # Expected: 85
    print("Bob balance:", bob.get_balance())       # Expected: 80

    print("\nAlice history:")
    for line in alice.get_history():
        print(line)

    print("\nBob history:")
    for line in bob.get_history():
        print(line)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

And the subclasses got their own __str__ and __repr__ too:

class CheckingAccount(BankAccount):
    def __init__(self, name, balance, overdraft_limit = 16):
        super().__init__(name, balance)
        self.overdraft_limit = overdraft_limit

    def __str__(self):
        return f"This is the Checking Account, the name of the owner is {self.name} and the balance is {self.balance}."

    def __repr__(self):
        return f"Checking Account: (name = {self.name}, balance = {self.balance}, overdraft_limit = {self.overdraft_limit})"

    def withdraw(self, amount):
        if self.balance >= amount:
            self.balance -= amount
            self.history.append(f"Amount {amount} withdrawn from {self.name} account. Total Balance = ${self.balance}")
            return self.balance
        else:
            if self.balance - amount -35 < -self.overdraft_limit:
                self.history.append(f"Withdrawal rejected! exceeded the overdraft limit")
                return self.balance
            self.balance -= (amount + 35)
            self.history.append(f"Overdraft: Withdrew ${amount} with $35 fee. New balance: ${self.balance}")
            return self.balance

    def transfer(self, amount, recipient):
        if isinstance(recipient, BankAccount):
            if self.balance >= amount:
                self.balance -= amount
                recipient.balance += amount
                self.history.append(f"Transferred ${amount} to {recipient.name}")
                recipient.history.append(f"Received ${amount} from {self.name}")
                return self.balance
            self.balance -= (amount + 35)
            self.history.append(f"Overdraft: Tansferred ${amount} with $35 fee to {recipient.name}. New balance: ${self.balance}")
            recipient.balance += amount
            self.history.append(f"Received ${amount} from {self.name}")
            return self.balance
        else:
            print("Not a valid bank account!")
            return self.balance

class SavingsAccount(BankAccount):
    def __init__(self, name, balance, intrest_rate = 0.02):
        super().__init__(name, balance)
        self.intrest_rate = intrest_rate

    def __str__(self):
        return f"This is the Savings Account, the name of the owner is {self.name} and the balance is {self.balance}."

    def __repr__(self):
        return f"Savings Account: (name = {self.name}, balance = {self.balance}, intrest_rate = {self.intrest_rate})"

    def apply_intrest(self):
        intrest = self.balance * self.intrest_rate
        self.balance += intrest
        self.history.append(f"Interest applied: ${intrest}. New balance: ${self.balance}")
        return self.balance


def main():

    # --- Checking Account Tests ---
    print("=== Checking Account ===")
    checking = CheckingAccount("Alice", 100, overdraft_limit=100)

    checking.withdraw(50)      # Normal: balance 50
    checking.withdraw(80)      # Overdraft: balance -65 (80 + 35 fee)
    checking.withdraw(100)     # Exceeds overdraft limit: should fail, balance stays -65
    checking.deposit(20)       # Balance -45

    print("Checking balance:", checking.get_balance())  # Expected: -45
    print("Checking history:")
    for line in checking.get_history():
        print(line)

    # --- Savings Account Tests ---
    print("\n=== Savings Account ===")
    savings = SavingsAccount("Bob", 1000, intrest_rate=0.05)
    savings.apply_intrest()     # Balance 1050
    savings.withdraw(2000)       # Should fail (no overdraft)
    savings.withdraw(50)         # Balance 1000

    print("Savings balance:", savings.get_balance())  # Expected: 1000
    print("Savings history:")
    for line in savings.get_history():
        print(line)

    # --- Cross-account Transfer ---
    print("\n=== Cross-Account Transfer ===")
    checking2 = CheckingAccount("Charlie", 50, overdraft_limit=100)
    savings2 = SavingsAccount("Dana", 200, intrest_rate=0.02)

    checking2.transfer(75, savings2)   # Charlie overdrafts by 25 + 35 fee. Dana receives 75.
    print("Charlie balance:", checking2.get_balance())   # Expected: -60
    print("Dana balance:", savings2.get_balance())       # Expected: 275

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The distinction between __str__ and __repr__ took a while to settle:

  • __str__ is for humans — print(account) should give readable output
  • __repr__ is for developers — repr(account) should give enough detail to reconstruct the object
  • __eq__ lets account1 == account2 mean something real: same name, same balance

Before dunders, printing an object gave something like <__main__.BankAccount object at 0x7f...>. After dunders, it gives BankAccount(Alice, 85.0). The object speaks for itself.


Day 33 — Composition vs Inheritance: A Library System

This was the conceptual centrepiece of the week. The question: when should classes inherit, and when should they contain each other?

The rule I took away: "is-a" → inheritance. "has-a" → composition.

A CheckingAccount is a BankAccount. Inheritance makes sense.
A Library has Books and Members. Composition makes sense. A library is not a book — it shouldn't inherit from one.

from datetime import datetime, timedelta


class Book:
    def __init__(self, title, author, isbn, is_available=True):
        self.title = title
        self.author = author
        self.isbn = isbn
        self.is_available = is_available

    def __str__(self):
        return f"{self.title} by {self.author} (ISBN: {self.isbn})"

    def mark_borrowed(self):
        self.is_available = False

    def mark_returned(self):
        self.is_available = True


class Member:
    def __init__(self, name, member_id):
        self.name = name
        self.member_id = member_id
        self.loans = []  

    def __str__(self):
        return f"{self.name} (ID: {self.member_id})"

    def borrow_book(self, book, library):
        """Ask library to loan a book to this member."""
        if not isinstance(book, Book):
            print("Invalid book.")
            return False

        if not book.is_available:
            print("Book is currently not available")
            return False

        # Create the Loan (composition: Loan HAS Book and Member)
        due = (datetime.now() + timedelta(days=14)).strftime("%Y-%m-%d")
        loan = Loan(book, self, due)

        # Track it everywhere
        self.loans.append(loan)
        library.record_loan(loan)
        return True

    def return_book(self, loan, library):
        """Return a specific loan via the library."""
        if loan in self.loans:
            self.loans.remove(loan)
            library.close_loan(loan)
        else:
            print("Loan not found for this member.")


class Loan:
    def __init__(self, book, member, due_date):
        self.book = book          # Loan HAS a Book
        self.member = member      # Loan HAS a Member
        self.due_date = due_date

    def __str__(self):
        return f"Loan: {self.book.title} to {self.member.name}, due {self.due_date}"

    def __repr__(self):
        return f"Loan(book={self.book!r}, member={self.member!r}, due_date={self.due_date!r})"


class Library:
    def __init__(self, name):
        self.name = name
        self.books = []      # Library HAS Books
        self.members = []    # Library HAS Members
        self.loans = []      # Library HAS Loans

    def __str__(self):
        return f"{self.name}: {len(self.books)} books, {len(self.members)} members"

    def add_book(self, book):
        if isinstance(book, Book):
            self.books.append(book)

    def register_member(self, member):
        if isinstance(member, Member):
            self.members.append(member)

    def record_loan(self, loan):
        """Called by Member.borrow_book to register a loan."""
        if isinstance(loan, Loan):
            loan.book.mark_borrowed()
            self.loans.append(loan)

    def close_loan(self, loan):
        """Called by Member.return_book to close a loan."""
        if loan in self.loans:
            loan.book.mark_returned()
            self.loans.remove(loan)

    def get_available_books(self):
        return [book for book in self.books if book.is_available]



if __name__ == "__main__":
    lib = Library("CS50 Library")

    b1 = Book("The C Programming Language", "Kernighan & Ritchie", "9780131103627")
    b2 = Book("Clean Code", "Robert C. Martin", "9780132350884")

    lib.add_book(b1)
    lib.add_book(b2)

    alice = Member("Alice", "M001")
    lib.register_member(alice)

    print("Available before borrow:", [b.title for b in lib.get_available_books()])

    alice.borrow_book(b1, lib)

    print("Available after borrow:", [b.title for b in lib.get_available_books()])
    print("Alice's loans:", alice.loans)

    alice.return_book(alice.loans[0], lib)

    print("Available after return:", [b.title for b in lib.get_available_books()])
Enter fullscreen mode Exit fullscreen mode

Four classes. Each one focused. Each one responsible for exactly its own data:

  • Book knows if it's available
  • Member knows their loans
  • Loan knows its book, member, and due date
  • Library coordinates everything without owning any individual item's logic

The Loan class is the key insight here. It's the relationship between a Book and a Member, captured as its own object. When a loan is created inside borrow_book(), it's automatically due in 14 days via timedelta(days=14). When it's returned, close_loan() flips the book's availability back. Clean handoffs, no tight coupling.

That list comprehension in get_available_books()[book for book in self.books if book.is_available] — is Week 3 knowledge powering Week 5 design.


Day 34 — The Week 5 Project: A Task Management System

The final project pulled the whole week together: a task management system with User, Project, and Task objects, property validation using regex, and proper OOP throughout.

from datetime import date
import re

#Build a task management system with users, projects, tasks, deadlines
class User:
    def __init__(self, name):
        self.name = name
        self.projects = {}

    def __str__(self):
        return f"The User name is {self.name}"

    def __repr__(self):
        return f"User: (name = {self.name})"

    def create_project(self, title, deadline):
        project = Project(title, deadline)
        self.projects[project.title] = project
        return project

    def mark_complete(self, project_title):
        project = self.projects[project_title]
        project.completed = True

    def remove_project(self, project_title):
        del self.projects[project_title]

    def show_projects(self):
        for project in self.projects.values():
            status = "overdue" if project.is_overdue else "ok"
            print(f"Project title: {project.title} | Deadline: {project.deadline} | Status: {status}")

    @property
    def name(self):
        return self._name
    @name.setter
    def name(self, name):
        if match := re.search(r"^(\w+ ?)$", name, re.IGNORECASE):
            self._name = match.group(1)
        else:
            raise ValueError("Enter Words!")

class Project:
    def __init__(self, title, deadline, completed = False):
        self.title = title
        self.deadline = deadline
        self.completed = completed
        self.tasks = []

    def __str__(self):
        return f"The Project Title is {self.title} and the deadline is {self.deadline}"

    def __repr__(self):
        return f"Project: (title = {self.title}, deadline = {self.deadline}, completed = {self.completed})"

    def add_task(self, task):
        self.tasks.append(task)

    def remove_task(self, task):
        self.tasks.remove(task)

    def view_tasks(self):
        for task in self.tasks:
            print(task)

    def is_overdue(self):
        return not self.completed and self.deadline < date.today()

    @property
    def deadline(self):
        return self._deadline
    @deadline.setter
    def deadline(self, deadline):
        if match := re.search(r"^(\d{2})/ ?(\d{2})/ ?(\d{4})$", deadline):
            try:
                self._deadline = date(int(match.group(3)), int(match.group(2)), int(match.group(1)))
            except ValueError:
                print("Day should be from 1 - 31, Month must be from 1-12")
        else:
            raise ValueError("Enter the deadline in dd/mm/yyyy")




def main():

    # --- Test User creation and string representation ---
    print("=" * 50)
    print("TEST 1: User Creation")
    print("=" * 50)
    alice = User("Alice")
    print(f"__str__: {alice}")
    print(f"__repr__: {repr(alice)}")
    print()

    # --- Test Project creation via User ---
    print("=" * 50)
    print("TEST 2: Project Creation")
    print("=" * 50)
    website = alice.create_project("Website", "05/06/2026")
    mobile_app = alice.create_project("MobileApp", "01/01/2025")
    print(f"__str__: {website}")
    print(f"__repr__: {repr(website)}") 
    print()

    # --- Test Task management ---
    print("=" * 50)
    print("TEST 3: Task Management")
    print("=" * 50)
    website.add_task("Design homepage")
    website.add_task("Setup database")
    website.add_task("Write API")
    print("Tasks in 'Website':")
    website.view_tasks()

    website.remove_task("Setup database")
    print("\nAfter removing 'Setup database':")
    website.view_tasks()
    print()

    # --- Test Overdue Check ---
    print("=" * 50)
    print("TEST 4: Overdue Check")
    print("=" * 50)
    print(f"Website overdue? {website.is_overdue()}")
    print(f"MobileApp overdue? {mobile_app.is_overdue()}")
    print()

    # --- Test Show Projects ---
    print("=" * 50)
    print("TEST 5: Show All Projects")
    print("=" * 50)
    alice.show_projects()
    print()

    # --- Test Mark Complete ---
    print("=" * 50)
    print("TEST 6: Mark Complete")
    print("=" * 50)
    alice.mark_complete("Website")
    print(f"After marking complete: {repr(website)}")
    print(f"Website overdue? {website.is_overdue()}")
    print()

    # --- Test Remove Project ---
    print("=" * 50)
    print("TEST 7: Remove Project")
    print("=" * 50)
    alice.remove_project("MobileApp")
    alice.show_projects()
    print()

    # --- Test Name Validation ---
    print("=" * 50)
    print("TEST 8: Validation")
    print("=" * 50)
    try:
        bad_user = User("Alice123!!!")
    except ValueError as e:
        print(f"Caught expected error: {e}")

    try:
        bad_project = Project("Bad", "not-a-date")
    except ValueError as e:
        print(f"Caught expected error: {e}")




if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

This is the most mature code I've written so far. A few things that stand out:

  • Walrus operator in settersif match := re.search(...) assigns and tests in one expression. The deadline setter validates and converts a "dd/mm/yyyy" string into a real date object using regex capture groups, raising a proper ValueError if it fails.
  • is_overdue() uses date.today() — comparing a stored deadline against today's date to compute real status dynamically.
  • show_projects() calls is_overdue() on each project and reports "overdue" or "ok" without needing any stored status field — the answer is computed live from the data.
  • Eight structured tests in main() — each labelled, each verifying a specific feature. I started writing tests like this during the week and it immediately made debugging faster.

The walrus operator (:=) was something I found while writing the regex validation and it immediately became my favourite small Python feature. Assign and check in one line. Clean.


📁 What I Built This Week

Project File Concepts Used
Bank Account bank_account.py __init__, __str__, @property, deposit/withdraw/transfer/history
Account Types account_types.py Inheritance, super(), method overriding, overdraft, interest
Dunder Methods bank_objects.py + account_objects.py __str__, __repr__, __eq__, subclass dunders
Library System library.py Composition, 4 classes, timedelta, list comprehension
Task Manager tasks.py OOP throughout, walrus operator, regex in setters, date.today(), 8 test cases

All the code is on GitHub — every week, every file:
👉 github.com/Omk4314/progress-on-python


What Actually Clicked This Week

  • __init__ sets state, not creates objects. The object exists before __init__ runs. It just gets its starting values there.
  • super().__init__() is not optional. Forgetting it in a subclass means the parent's attributes don't exist. The error that follows is confusing until you know why.
  • @property setters are validation layers. They intercept writes and enforce rules before anything gets stored. The object protects itself.
  • "is-a" vs "has-a" is the real inheritance question. Once I had that frame, every design decision in the library became obvious.
  • Dunders make objects feel native. print(account) giving BankAccount(Alice, 85.0) instead of a memory address is a small thing that changes everything about working with your own classes.
  • The walrus operator (:=) is genuinely useful inside property setters — assign the regex match and test it in one expression.

What I Want to Learn Next

Week 6 is coming and I can already see what's missing:

  • Abstract base classes — enforcing that subclasses implement certain methods
  • Class methods vs static methods vs instance methods — I've touched @classmethod but not the full picture
  • APIs — pulling live data from the internet into Python objects
  • pytest — writing real tests, not just a main() with print statements
  • A bigger project — something that uses multiple classes across multiple files with real data

Five weeks in. The code is starting to have architecture.

Drop your Week 5 projects in the comments — I want to see how others are tackling OOP for the first time.

See you in Week 6. 🐍


Week 5 complete. Objects everywhere. No going back.

Top comments (0)