Python Full Stack Web Developer Course

Week 3: Object-Oriented Programming Advanced Concepts

Property Decorators (Getters and Setters)

Understanding Properties in Python

In object-oriented programming, one of the key principles is encapsulation—the idea of bundling data and the methods that work on that data together, while restricting direct access to some of the object's components. In many OOP languages, this is achieved through private variables and public getters and setters.

Python, however, takes a different approach. While Python doesn't have true private variables (all attributes are technically accessible), it provides a powerful mechanism called "properties" that allows you to create getter and setter methods that behave like attributes. This gives you the best of both worlds: the simplicity of attribute access with the control and flexibility of method calls.

In today's session, we'll explore how to use property decorators to implement controlled attribute access, data validation, computed properties, and more. By the end, you'll understand how to use properties to write cleaner, more maintainable code while maintaining proper encapsulation.

Today's File Structure

For today's lesson, we'll create a new Python module in our project. Ensure you have the following directory structure:

project_root/
├── properties/
│   ├── __init__.py  (empty file to make the folder a package)
│   ├── property_basics.py
│   ├── property_decorators.py
│   ├── computed_properties.py
│   ├── property_validation.py
│   └── real_world_examples.py

All code examples will be saved in these files, allowing you to organize and revisit these concepts easily.

The Basics of Properties

Let's start by understanding the traditional approach to getters and setters in Python, and then see how properties improve on that. Create a file named property_basics.py with the following code:

# File: properties/property_basics.py

class PersonTraditional:
    """A class demonstrating the traditional getter/setter approach"""
    
    def __init__(self, first_name, last_name, age):
        self._first_name = first_name
        self._last_name = last_name
        self._age = age
    
    def get_first_name(self):
        return self._first_name
    
    def set_first_name(self, value):
        if not isinstance(value, str):
            raise TypeError("First name must be a string")
        if len(value) == 0:
            raise ValueError("First name cannot be empty")
        self._first_name = value
    
    def get_last_name(self):
        return self._last_name
    
    def set_last_name(self, value):
        if not isinstance(value, str):
            raise TypeError("Last name must be a string")
        if len(value) == 0:
            raise ValueError("Last name cannot be empty")
        self._last_name = value
    
    def get_age(self):
        return self._age
    
    def set_age(self, value):
        if not isinstance(value, int):
            raise TypeError("Age must be an integer")
        if value < 0 or value > 120:
            raise ValueError("Age must be between 0 and 120")
        self._age = value
    
    def get_full_name(self):
        return f"{self._first_name} {self._last_name}"


class PersonProperty:
    """A class demonstrating the property() function approach"""
    
    def __init__(self, first_name, last_name, age):
        self._first_name = first_name
        self._last_name = last_name
        self._age = age
    
    def _get_first_name(self):
        return self._first_name
    
    def _set_first_name(self, value):
        if not isinstance(value, str):
            raise TypeError("First name must be a string")
        if len(value) == 0:
            raise ValueError("First name cannot be empty")
        self._first_name = value
    
    # Create a property using the property() function
    first_name = property(_get_first_name, _set_first_name, 
                         doc="The person's first name")
    
    def _get_last_name(self):
        return self._last_name
    
    def _set_last_name(self, value):
        if not isinstance(value, str):
            raise TypeError("Last name must be a string")
        if len(value) == 0:
            raise ValueError("Last name cannot be empty")
        self._last_name = value
    
    last_name = property(_get_last_name, _set_last_name,
                        doc="The person's last name")
    
    def _get_age(self):
        return self._age
    
    def _set_age(self, value):
        if not isinstance(value, int):
            raise TypeError("Age must be an integer")
        if value < 0 or value > 120:
            raise ValueError("Age must be between 0 and 120")
        self._age = value
    
    age = property(_get_age, _set_age, doc="The person's age")
    
    def _get_full_name(self):
        return f"{self._first_name} {self._last_name}"
    
    # Read-only property (no setter)
    full_name = property(_get_full_name, doc="The person's full name")


# Demo traditional getter/setter approach
if __name__ == "__main__":
    print("Traditional getter/setter approach:")
    person1 = PersonTraditional("John", "Doe", 30)
    
    # Get attributes using getter methods
    print(f"Name: {person1.get_first_name()} {person1.get_last_name()}")
    print(f"Age: {person1.get_age()}")
    print(f"Full name: {person1.get_full_name()}")
    
    # Set attributes using setter methods
    person1.set_first_name("Jane")
    person1.set_age(32)
    
    print(f"Updated name: {person1.get_first_name()} {person1.get_last_name()}")
    print(f"Updated age: {person1.get_age()}")
    
    # Demo property() function approach
    print("\nProperty function approach:")
    person2 = PersonProperty("John", "Doe", 30)
    
    # Get attributes using properties (looks like attribute access)
    print(f"Name: {person2.first_name} {person2.last_name}")
    print(f"Age: {person2.age}")
    print(f"Full name: {person2.full_name}")
    
    # Set attributes using properties
    person2.first_name = "Jane"
    person2.age = 32
    
    print(f"Updated name: {person2.first_name} {person2.last_name}")
    print(f"Updated age: {person2.age}")
    
    # Access property docstrings
    print(f"\nDocstring for first_name: {PersonProperty.first_name.__doc__}")
    
    # Demonstrate validation
    try:
        person2.age = -5  # Should raise ValueError
    except ValueError as e:
        print(f"Validation error: {e}")
    
    try:
        person2.first_name = 123  # Should raise TypeError
    except TypeError as e:
        print(f"Validation error: {e}")
    
    # Try to set a read-only property
    try:
        person2.full_name = "Jane Smith"  # Should raise AttributeError
    except AttributeError as e:
        print(f"Read-only property error: {e}")

Code Breakdown:

Advantages of Properties:

Real-world analogy: Think of properties like smart home devices. From the user's perspective, they just flip a light switch as they always have. But behind the scenes, that switch might be connected to a smart system that performs additional actions (validation, logging) or makes intelligent decisions (computed properties). The interface remains simple, but the behavior is enhanced.

Property Decorators: The Pythonic Way

While the property() function approach is functional, Python provides an even cleaner syntax using decorators. Create a file named property_decorators.py with the following code:

# File: properties/property_decorators.py

class Person:
    """A class demonstrating property decorators"""
    
    def __init__(self, first_name, last_name, age):
        # Initialize with private attributes
        # Note: using private attributes prevents name conflicts with properties
        self._first_name = first_name
        self._last_name = last_name
        self._age = age
    
    @property
    def first_name(self):
        """The person's first name"""
        return self._first_name
    
    @first_name.setter
    def first_name(self, value):
        if not isinstance(value, str):
            raise TypeError("First name must be a string")
        if len(value) == 0:
            raise ValueError("First name cannot be empty")
        self._first_name = value
    
    @property
    def last_name(self):
        """The person's last name"""
        return self._last_name
    
    @last_name.setter
    def last_name(self, value):
        if not isinstance(value, str):
            raise TypeError("Last name must be a string")
        if len(value) == 0:
            raise ValueError("Last name cannot be empty")
        self._last_name = value
    
    @property
    def age(self):
        """The person's age"""
        return self._age
    
    @age.setter
    def age(self, value):
        if not isinstance(value, int):
            raise TypeError("Age must be an integer")
        if value < 0 or value > 120:
            raise ValueError("Age must be between 0 and 120")
        self._age = value
    
    @property
    def full_name(self):
        """The person's full name (read-only)"""
        return f"{self._first_name} {self._last_name}"
    
    @property
    def is_adult(self):
        """Boolean indicating if the person is an adult (18+)"""
        return self._age >= 18


class Rectangle:
    """A class representing a rectangle with property decorators"""
    
    def __init__(self, width, height):
        self._width = width
        self._height = height
    
    @property
    def width(self):
        """The rectangle's width"""
        return self._width
    
    @width.setter
    def width(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError("Width must be a number")
        if value <= 0:
            raise ValueError("Width must be positive")
        self._width = value
    
    @property
    def height(self):
        """The rectangle's height"""
        return self._height
    
    @height.setter
    def height(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError("Height must be a number")
        if value <= 0:
            raise ValueError("Height must be positive")
        self._height = value
    
    @property
    def area(self):
        """The rectangle's area (width * height)"""
        return self._width * self._height
    
    @property
    def perimeter(self):
        """The rectangle's perimeter (2 * (width + height))"""
        return 2 * (self._width + self._height)
    
    @property
    def is_square(self):
        """Boolean indicating if the rectangle is a square"""
        return self._width == self._height
    
    def __str__(self):
        return f"Rectangle(width={self._width}, height={self._height})"
    
    # Deleter example
    @width.deleter
    def width(self):
        print("Cannot delete width attribute!")
        # Not actually deleting the attribute
    
    @height.deleter
    def height(self):
        print("Cannot delete height attribute!")
        # Not actually deleting the attribute


# Demo property decorators
if __name__ == "__main__":
    print("Property decorators example:")
    person = Person("John", "Doe", 30)
    
    # Get attributes using properties
    print(f"Name: {person.first_name} {person.last_name}")
    print(f"Age: {person.age}")
    print(f"Full name: {person.full_name}")
    print(f"Is adult: {person.is_adult}")
    
    # Set attributes using properties
    person.first_name = "Jane"
    person.age = 17
    
    print(f"Updated name: {person.first_name} {person.last_name}")
    print(f"Updated age: {person.age}")
    print(f"Is adult: {person.is_adult}")
    
    # Rectangle example
    print("\nRectangle example:")
    rect = Rectangle(10, 5)
    print(f"Rectangle: {rect}")
    print(f"Width: {rect.width}")
    print(f"Height: {rect.height}")
    print(f"Area: {rect.area}")
    print(f"Perimeter: {rect.perimeter}")
    print(f"Is square: {rect.is_square}")
    
    # Modify rectangle
    rect.width = 8
    rect.height = 8
    print(f"Updated rectangle: {rect}")
    print(f"Area: {rect.area}")
    print(f"Perimeter: {rect.perimeter}")
    print(f"Is square: {rect.is_square}")
    
    # Demonstrate validation
    try:
        rect.width = -5  # Should raise ValueError
    except ValueError as e:
        print(f"Validation error: {e}")
    
    try:
        rect.height = "invalid"  # Should raise TypeError
    except TypeError as e:
        print(f"Validation error: {e}")
    
    # Demonstrate deleters
    try:
        del rect.width  # Should invoke the deleter
        print(f"Width after deletion: {rect.width}")
    except AttributeError as e:
        print(f"Deletion error: {e}")
    
    # Try to set a read-only property
    try:
        rect.area = 100  # Should raise AttributeError
    except AttributeError as e:
        print(f"Read-only property error: {e}")

Code Breakdown:

Decorator Syntax Explained:

  1. @property - This creates a property getter. It's placed above a method that retrieves the property value.
  2. @name.setter - This creates a property setter. The method name must be the same as the getter, and it takes a value parameter.
  3. @name.deleter - This creates a property deleter, which runs when del obj.name is called.

Property Decorator Order:

The order of property decorators is important:

  1. Define the getter first with @property
  2. Define the setter with @name.setter (if needed)
  3. Define the deleter with @name.deleter (if needed)

Real-world analogy: Property decorators are like installing a smart thermostat in your home. The interface (turning a dial or pressing buttons) remains familiar, but the thermostat now has validation (prevents setting to extreme temperatures), computation (calculating energy usage), and additional behavior (learning your schedule). Users still interact with it in the same simple way, but it's now smarter and safer.

Computed Properties

One of the most powerful uses of properties is creating computed or derived attributes—properties that calculate their values on the fly based on other attributes. Create a file named computed_properties.py with the following code:

# File: properties/computed_properties.py

import math
from datetime import datetime, date


class Circle:
    """A class representing a circle with computed properties"""
    
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        """The circle's radius"""
        return self._radius
    
    @radius.setter
    def radius(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError("Radius must be a number")
        if value <= 0:
            raise ValueError("Radius must be positive")
        self._radius = value
    
    @property
    def diameter(self):
        """The circle's diameter (2 * radius)"""
        return self._radius * 2
    
    @diameter.setter
    def diameter(self, value):
        """Setting diameter updates the radius"""
        if not isinstance(value, (int, float)):
            raise TypeError("Diameter must be a number")
        if value <= 0:
            raise ValueError("Diameter must be positive")
        self._radius = value / 2
    
    @property
    def area(self):
        """The circle's area (π * radius²)"""
        return math.pi * self._radius ** 2
    
    @property
    def circumference(self):
        """The circle's circumference (2 * π * radius)"""
        return 2 * math.pi * self._radius


class Person:
    """A class demonstrating computed properties based on multiple attributes"""
    
    def __init__(self, first_name, last_name, birth_date=None):
        self._first_name = first_name
        self._last_name = last_name
        self._birth_date = birth_date
    
    @property
    def first_name(self):
        return self._first_name
    
    @first_name.setter
    def first_name(self, value):
        self._first_name = value
    
    @property
    def last_name(self):
        return self._last_name
    
    @last_name.setter
    def last_name(self, value):
        self._last_name = value
    
    @property
    def birth_date(self):
        return self._birth_date
    
    @birth_date.setter
    def birth_date(self, value):
        if value is not None and not isinstance(value, date):
            raise TypeError("Birth date must be a date object or None")
        self._birth_date = value
    
    @property
    def full_name(self):
        """The person's full name (first + last)"""
        return f"{self._first_name} {self._last_name}"
    
    @property
    def age(self):
        """Calculate the person's age based on birth date"""
        if self._birth_date is None:
            return None
        
        today = date.today()
        
        # Calculate the age
        age = today.year - self._birth_date.year
        
        # Adjust age if birthday hasn't occurred yet this year
        if (today.month, today.day) < (self._birth_date.month, self._birth_date.day):
            age -= 1
            
        return age
    
    @property
    def initials(self):
        """Get the person's initials"""
        return f"{self._first_name[0]}.{self._last_name[0]}."


class Temperature:
    """A class demonstrating properties that convert between units"""
    
    def __init__(self, celsius=0):
        self._celsius = celsius
    
    @property
    def celsius(self):
        """Temperature in Celsius"""
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError("Temperature must be a number")
        self._celsius = value
    
    @property
    def fahrenheit(self):
        """Temperature in Fahrenheit"""
        return (self._celsius * 9/5) + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        """Setting Fahrenheit updates Celsius"""
        if not isinstance(value, (int, float)):
            raise TypeError("Temperature must be a number")
        self._celsius = (value - 32) * 5/9
    
    @property
    def kelvin(self):
        """Temperature in Kelvin"""
        return self._celsius + 273.15
    
    @kelvin.setter
    def kelvin(self, value):
        """Setting Kelvin updates Celsius"""
        if not isinstance(value, (int, float)):
            raise TypeError("Temperature must be a number")
        if value < 0:
            raise ValueError("Temperature in Kelvin cannot be negative")
        self._celsius = value - 273.15


# Demo computed properties
if __name__ == "__main__":
    print("Circle example:")
    circle = Circle(5)
    print(f"Radius: {circle.radius}")
    print(f"Diameter: {circle.diameter}")
    print(f"Area: {circle.area:.2f}")
    print(f"Circumference: {circle.circumference:.2f}")
    
    # Change radius and observe computed properties
    circle.radius = 7
    print(f"\nAfter changing radius to {circle.radius}:")
    print(f"Diameter: {circle.diameter}")
    print(f"Area: {circle.area:.2f}")
    print(f"Circumference: {circle.circumference:.2f}")
    
    # Change diameter and observe radius
    circle.diameter = 20
    print(f"\nAfter changing diameter to {circle.diameter}:")
    print(f"Radius: {circle.radius}")
    print(f"Area: {circle.area:.2f}")
    print(f"Circumference: {circle.circumference:.2f}")
    
    # Person example
    print("\nPerson example:")
    person = Person("John", "Doe", date(1990, 5, 15))
    print(f"Full name: {person.full_name}")
    print(f"Initials: {person.initials}")
    print(f"Birth date: {person.birth_date}")
    print(f"Age: {person.age}")
    
    # Change name and observe computed properties
    person.first_name = "Jane"
    print(f"\nAfter changing first name to {person.first_name}:")
    print(f"Full name: {person.full_name}")
    print(f"Initials: {person.initials}")
    
    # Temperature example
    print("\nTemperature example:")
    temp = Temperature(25)  # 25°C
    print(f"Celsius: {temp.celsius}°C")
    print(f"Fahrenheit: {temp.fahrenheit}°F")
    print(f"Kelvin: {temp.kelvin}K")
    
    # Change temperature in different units
    print("\nChanging to 68°F:")
    temp.fahrenheit = 68
    print(f"Celsius: {temp.celsius:.2f}°C")
    print(f"Fahrenheit: {temp.fahrenheit}°F")
    print(f"Kelvin: {temp.kelvin:.2f}K")
    
    print("\nChanging to 300K:")
    temp.kelvin = 300
    print(f"Celsius: {temp.celsius:.2f}°C")
    print(f"Fahrenheit: {temp.fahrenheit:.2f}°F")
    print(f"Kelvin: {temp.kelvin}K")
    
    # Demonstrate validation
    try:
        temp.kelvin = -10  # Should raise ValueError
    except ValueError as e:
        print(f"\nValidation error: {e}")

Code Breakdown:

Key Concepts for Computed Properties:

  1. Lazy Evaluation: Computed properties calculate their values only when accessed, which is more efficient than calculating everything upfront.
  2. Derived Data: Computed properties can derive their values from other attributes, keeping your data model DRY (Don't Repeat Yourself).
  3. Bidirectional Relationships: Properties can have setters that update other related properties, maintaining consistency.
  4. Caching: For expensive calculations, you might cache the result and only recalculate when dependencies change (not shown in examples).
  5. Domain Logic: Properties can encapsulate domain-specific logic, like the age calculation that correctly handles birthdays.

Real-world analogy: Computed properties are like the dashboard in your car. When you look at the speedometer, it doesn't store your speed—it calculates it on demand based on other factors (wheel rotation). The fuel gauge similarly computes based on the fuel level. The "miles to empty" display combines multiple factors (fuel level, average consumption) to derive a useful value. All of these are "computed properties" that give you insight based on the current state of the system.

Property Validation Patterns

Properties provide an excellent place to validate input data before it's stored in your objects. Let's explore some validation patterns in detail. Create a file named property_validation.py with the following code:

# File: properties/property_validation.py

import re
from datetime import datetime


class User:
    """A class demonstrating validation patterns with properties"""
    
    def __init__(self, username, email, password, birth_date=None):
        # Note: we don't validate in __init__ since property setters will do that
        # Instead, we use the property setters directly
        self._username = None
        self._email = None
        self._password = None
        self._birth_date = None
        
        # Now set the attributes through the properties to trigger validation
        self.username = username
        self.email = email
        self.password = password
        self.birth_date = birth_date
    
    @property
    def username(self):
        """
        The user's username - must be 3-20 characters, alphanumeric with underscores
        """
        return self._username
    
    @username.setter
    def username(self, value):
        # Type checking
        if not isinstance(value, str):
            raise TypeError("Username must be a string")
        
        # Value validation - length
        if len(value) < 3 or len(value) > 20:
            raise ValueError("Username must be 3-20 characters long")
        
        # Value validation - pattern (alphanumeric + underscore)
        if not re.match(r'^[a-zA-Z0-9_]+$', value):
            raise ValueError("Username must contain only letters, numbers, and underscores")
        
        # If we've passed all validations, set the value
        self._username = value
    
    @property
    def email(self):
        """The user's email address - must be a valid email format"""
        return self._email
    
    @email.setter
    def email(self, value):
        # Type checking
        if not isinstance(value, str):
            raise TypeError("Email must be a string")
        
        # Value validation - simplified email pattern
        if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', value):
            raise ValueError("Email must be a valid email address")
        
        self._email = value
    
    @property
    def password(self):
        """
        The user's password - must be 8+ characters with letters and numbers
        Note: In a real system, we would never return the actual password
        """
        return "********"  # Hide the actual password
    
    @password.setter
    def password(self, value):
        # Type checking
        if not isinstance(value, str):
            raise TypeError("Password must be a string")
        
        # Value validation - length
        if len(value) < 8:
            raise ValueError("Password must be at least 8 characters long")
        
        # Value validation - complexity
        if not any(c.isalpha() for c in value):
            raise ValueError("Password must contain at least one letter")
        
        if not any(c.isdigit() for c in value):
            raise ValueError("Password must contain at least one number")
        
        # In a real system, we would hash the password before storing
        self._password = value
    
    @property
    def birth_date(self):
        """The user's birth date (optional) - must be in the past"""
        return self._birth_date
    
    @birth_date.setter
    def birth_date(self, value):
        # Allow None for optional fields
        if value is None:
            self._birth_date = None
            return
        
        # Type checking
        if not isinstance(value, datetime):
            raise TypeError("Birth date must be a datetime object")
        
        # Value validation - must be in the past
        if value > datetime.now():
            raise ValueError("Birth date cannot be in the future")
        
        self._birth_date = value
    
    @property
    def age(self):
        """Calculate the user's age based on birth date"""
        if self._birth_date is None:
            return None
        
        today = datetime.now()
        age = today.year - self._birth_date.year
        
        # Adjust age if birthday hasn't occurred yet this year
        if (today.month, today.day) < (self._birth_date.month, self._birth_date.day):
            age -= 1
            
        return age


class BankAccount:
    """A class demonstrating validation patterns for financial data"""
    
    def __init__(self, account_number, owner_name, balance=0):
        self._account_number = None
        self._owner_name = None
        self._balance = None
        
        # Set via properties to trigger validation
        self.account_number = account_number
        self.owner_name = owner_name
        self.balance = balance
        self._transaction_history = []
    
    @property
    def account_number(self):
        """
        The account number - must be exactly 10 digits
        Note: In a real system, we might mask this for security
        """
        return self._account_number
    
    @account_number.setter
    def account_number(self, value):
        # Type checking
        if not isinstance(value, str):
            raise TypeError("Account number must be a string")
        
        # Value validation - format
        if not re.match(r'^\d{10}$', value):
            raise ValueError("Account number must be exactly 10 digits")
        
        self._account_number = value
    
    @property
    def owner_name(self):
        """The name of the account owner"""
        return self._owner_name
    
    @owner_name.setter
    def owner_name(self, value):
        # Type checking
        if not isinstance(value, str):
            raise TypeError("Owner name must be a string")
        
        # Value validation
        if len(value.strip()) == 0:
            raise ValueError("Owner name cannot be empty")
        
        self._owner_name = value
    
    @property
    def balance(self):
        """The account balance - must be non-negative"""
        return self._balance
    
    @balance.setter
    def balance(self, value):
        # Type checking
        if not isinstance(value, (int, float)):
            raise TypeError("Balance must be a number")
        
        # Value validation
        if value < 0:
            raise ValueError("Balance cannot be negative")
        
        self._balance = value
    
    @property
    def transaction_history(self):
        """The account's transaction history (read-only)"""
        # Return a copy to prevent external modification
        return self._transaction_history.copy()
    
    def deposit(self, amount):
        """Deposit money into the account"""
        # Validate the amount
        if not isinstance(amount, (int, float)):
            raise TypeError("Deposit amount must be a number")
        
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        
        # Update the balance
        old_balance = self._balance
        self._balance += amount
        
        # Record the transaction
        transaction = {
            'type': 'deposit',
            'amount': amount,
            'old_balance': old_balance,
            'new_balance': self._balance,
            'timestamp': datetime.now()
        }
        self._transaction_history.append(transaction)
        
        return self._balance
    
    def withdraw(self, amount):
        """Withdraw money from the account"""
        # Validate the amount
        if not isinstance(amount, (int, float)):
            raise TypeError("Withdrawal amount must be a number")
        
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive")
        
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        
        # Update the balance
        old_balance = self._balance
        self._balance -= amount
        
        # Record the transaction
        transaction = {
            'type': 'withdrawal',
            'amount': amount,
            'old_balance': old_balance,
            'new_balance': self._balance,
            'timestamp': datetime.now()
        }
        self._transaction_history.append(transaction)
        
        return self._balance


# Demo property validation
if __name__ == "__main__":
    print("User validation example:")
    try:
        # Valid user creation
        user = User(
            username="john_doe",
            email="john@example.com",
            password="password123",
            birth_date=datetime(1990, 5, 15)
        )
        print(f"User created: {user.username}, {user.email}")
        print(f"Password (masked): {user.password}")
        print(f"Birth date: {user.birth_date}")
        print(f"Age: {user.age}")
        
        # Invalid username
        user.username = "j"  # Too short
    except ValueError as e:
        print(f"Validation error: {e}")
    
    try:
        # Invalid email
        user.email = "invalid-email"
    except ValueError as e:
        print(f"Validation error: {e}")
    
    try:
        # Invalid password
        user.password = "weak"  # Too short
    except ValueError as e:
        print(f"Validation error: {e}")
    
    try:
        # Invalid birth date (future)
        user.birth_date = datetime(2030, 1, 1)
    except ValueError as e:
        print(f"Validation error: {e}")
    
    print("\nBankAccount validation example:")
    try:
        # Valid account creation
        account = BankAccount("1234567890", "Jane Smith", 1000)
        print(f"Account created: {account.account_number}, {account.owner_name}")
        print(f"Initial balance: ${account.balance}")
        
        # Deposit and withdraw
        print(f"After deposit of $500: ${account.deposit(500)}")
        print(f"After withdrawal of $200: ${account.withdraw(200)}")
        
        # View transaction history
        print("\nTransaction history:")
        for i, transaction in enumerate(account.transaction_history, 1):
            print(f"{i}. {transaction['type'].capitalize()}: ${transaction['amount']} (Balance: ${transaction['new_balance']})")
        
        # Invalid deposit
        account.deposit(-100)  # Negative amount
    except ValueError as e:
        print(f"Validation error: {e}")
    
    try:
        # Invalid withdrawal
        account.withdraw(2000)  # Insufficient funds
    except ValueError as e:
        print(f"Validation error: {e}")
    
    try:
        # Invalid account number
        account.account_number = "12345"  # Not 10 digits
    except ValueError as e:
        print(f"Validation error: {e}")
    
    try:
        # Attempt to modify transaction history
        account.transaction_history.append({'fake': 'transaction'})
        print("Transaction history modified!")
    except AttributeError as e:
        print(f"Modification error: {e}")
    
    # Verify transaction history is unchanged
    print(f"Transaction history length: {len(account.transaction_history)}")

Code Breakdown:

Validation Patterns:

  1. Type Checking: Use isinstance() to ensure values are of the expected type.
  2. Value Validation: Check that values meet domain-specific requirements (length, pattern, range, etc.).
  3. Pattern Matching: Use regular expressions for complex pattern validation (username, email, account numbers).
  4. Business Rules: Enforce domain-specific business rules (birth date must be in the past, balance can't be negative).
  5. Defensive Copying: Return copies of mutable objects to prevent external modification.
  6. Data Privacy: Hide sensitive information in getters (password masking).

Initialization Pattern:

Note the pattern used in both classes:

  1. Initialize private attributes to None in __init__
  2. Then set attributes through properties to trigger validation
  3. This ensures that even during initialization, all values are validated

Real-world analogy: Property validation is like a security checkpoint. Before anyone can enter a secure building, they must pass through metal detectors, ID verification, and maybe even biometric scans. Similarly, before data can enter your object's internal state, it must pass through the validation "checkpoint" defined in your property setters. The more sensitive the data, the more thorough the validation should be.

Real-World Examples

Let's look at some real-world examples of how properties are used in web development and other contexts. Create a file named real_world_examples.py with the following code:

# File: properties/real_world_examples.py

import re
import json
from datetime import datetime, timedelta
import os


class Config:
    """A configuration class with lazy loading and type validation"""
    
    def __init__(self, config_file=None):
        self._config_file = config_file
        self._data = {}
        self._loaded = False
    
    def _load_if_needed(self):
        """Lazy load the config file"""
        if not self._loaded and self._config_file and os.path.exists(self._config_file):
            with open(self._config_file, 'r') as f:
                self._data = json.load(f)
            self._loaded = True
    
    @property
    def database_url(self):
        """Get the database URL"""
        self._load_if_needed()
        return self._data.get('database_url', 'sqlite:///default.db')
    
    @database_url.setter
    def database_url(self, value):
        if not isinstance(value, str):
            raise TypeError("Database URL must be a string")
        self._data['database_url'] = value
    
    @property
    def debug_mode(self):
        """Get the debug mode setting"""
        self._load_if_needed()
        return self._data.get('debug_mode', False)
    
    @debug_mode.setter
    def debug_mode(self, value):
        if not isinstance(value, bool):
            raise TypeError("Debug mode must be a boolean")
        self._data['debug_mode'] = value
    
    @property
    def api_keys(self):
        """Get API keys (dictionary)"""
        self._load_if_needed()
        return self._data.get('api_keys', {}).copy()  # Return a copy to prevent modification
    
    @api_keys.setter
    def api_keys(self, value):
        if not isinstance(value, dict):
            raise TypeError("API keys must be a dictionary")
        self._data['api_keys'] = value
    
    def save(self):
        """Save the configuration to the file"""
        if not self._config_file:
            raise ValueError("No config file specified")
        
        with open(self._config_file, 'w') as f:
            json.dump(self._data, f, indent=2)
        
        self._loaded = True


class URLBuilder:
    """A class for building URLs with proper encoding and validation"""
    
    def __init__(self, base_url):
        # Validate and store the base URL
        if not isinstance(base_url, str):
            raise TypeError("Base URL must be a string")
        
        # Ensure the base URL is properly formatted
        if not base_url.startswith(('http://', 'https://')):
            raise ValueError("Base URL must start with http:// or https://")
        
        # Remove trailing slash for consistency
        self._base_url = base_url.rstrip('/')
        
        # Initialize components
        self._path = ''
        self._query_params = {}
        self._fragment = ''
    
    @property
    def base_url(self):
        """The base URL (scheme + domain)"""
        return self._base_url
    
    @property
    def path(self):
        """The URL path"""
        return self._path
    
    @path.setter
    def path(self, value):
        if not isinstance(value, str):
            raise TypeError("Path must be a string")
        
        # Ensure the path starts with a slash but doesn't end with one
        self._path = '/' + value.strip('/') if value else ''
    
    @property
    def query_params(self):
        """The query parameters as a dictionary"""
        return self._query_params.copy()  # Return a copy to prevent modification
    
    def add_query_param(self, key, value):
        """Add a query parameter"""
        self._query_params[key] = value
    
    def clear_query_params(self):
        """Clear all query parameters"""
        self._query_params.clear()
    
    @property
    def fragment(self):
        """The URL fragment (the part after #)"""
        return self._fragment
    
    @fragment.setter
    def fragment(self, value):
        if not isinstance(value, str):
            raise TypeError("Fragment must be a string")
        
        # Remove the leading # if present
        self._fragment = value.lstrip('#')
    
    @property
    def url(self):
        """
        The complete URL formed by joining all components
        This is a calculated property based on all the components
        """
        url = self._base_url + self._path
        
        # Add query parameters if any
        if self._query_params:
            # Simple URL encoding for demonstration purposes
            # In a real implementation, use urllib.parse
            params = '&'.join(f'{k}={v}' for k, v in self._query_params.items())
            url += '?' + params
        
        # Add fragment if any
        if self._fragment:
            url += '#' + self._fragment
        
        return url


class CachedProperty:
    """A descriptor that caches the result of a property"""
    
    def __init__(self, func):
        self.func = func
        self.__doc__ = func.__doc__
    
    def __get__(self, instance, owner):
        if instance is None:
            return self
        
        # Calculate the value
        value = self.func(instance)
        
        # Cache the value on the instance
        setattr(instance, self.func.__name__, value)
        
        return value


class WebPageAnalyzer:
    """A class that analyzes web page content with cached properties"""
    
    def __init__(self, html_content):
        self.html_content = html_content
    
    @CachedProperty
    def word_count(self):
        """Count the number of words in the HTML content"""
        # This is a simplified example - in real life we would parse the HTML properly
        # and extract just the text content
        print("Calculating word count...")
        words = re.findall(r'\b\w+\b', self.html_content)
        return len(words)
    
    @CachedProperty
    def link_count(self):
        """Count the number of links in the HTML content"""
        print("Counting links...")
        links = re.findall(r']*href=[\'"]([^\'"]+)[\'"]', self.html_content)
        return len(links)
    
    @CachedProperty
    def image_count(self):
        """Count the number of images in the HTML content"""
        print("Counting images...")
        images = re.findall(r']*src=[\'"]([^\'"]+)[\'"]', self.html_content)
        return len(images)
    
    @property
    def stats(self):
        """Get a dictionary of all statistics"""
        return {
            'word_count': self.word_count,
            'link_count': self.link_count,
            'image_count': self.image_count
        }


class JWTToken:
    """A class for working with JWT tokens"""
    
    def __init__(self, token=None):
        self._token = token
        self._payload = None
        self._header = None
        self._signature = None
        self._expiry = None
        
        # If a token was provided, decode it
        if token:
            self._decode_token()
    
    def _decode_token(self):
        """Decode the token into its parts"""
        # This is a simplified implementation for demonstration
        # In a real implementation, use a JWT library
        
        try:
            parts = self._token.split('.')
            if len(parts) != 3:
                raise ValueError("Invalid JWT token format")
            
            # In a real implementation, these would be base64 decoded and parsed
            self._header = {"alg": "HS256", "typ": "JWT"}
            self._payload = {"sub": "1234567890", "name": "John Doe", "exp": int((datetime.now() + timedelta(hours=1)).timestamp())}
            self._signature = "signature"
            
            # Extract expiry time if present
            if "exp" in self._payload:
                self._expiry = datetime.fromtimestamp(self._payload["exp"])
        
        except Exception as e:
            raise ValueError(f"Failed to decode token: {e}")
    
    @property
    def token(self):
        """The JWT token string"""
        return self._token
    
    @token.setter
    def token(self, value):
        if not isinstance(value, str):
            raise TypeError("Token must be a string")
        
        self._token = value
        self._decode_token()
    
    @property
    def payload(self):
        """The token payload (claims)"""
        return self._payload.copy() if self._payload else None
    
    @property
    def header(self):
        """The token header"""
        return self._header.copy() if self._header else None
    
    @property
    def subject(self):
        """The subject claim from the payload"""
        return self._payload.get("sub") if self._payload else None
    
    @property
    def expiry(self):
        """The expiry time of the token"""
        return self._expiry
    
    @property
    def is_expired(self):
        """Check if the token is expired"""
        if not self._expiry:
            return False
        return datetime.now() > self._expiry
    
    @property
    def time_to_expiry(self):
        """Time remaining until the token expires"""
        if not self._expiry:
            return None
        
        if self.is_expired:
            return timedelta(0)
        
        return self._expiry - datetime.now()


# Demo real-world examples
if __name__ == "__main__":
    # Config example
    print("Config example:")
    # Uncomment to test with a real file
    # config = Config("config.json")
    config = Config()  # In-memory only for demo
    
    print(f"Default database URL: {config.database_url}")
    print(f"Default debug mode: {config.debug_mode}")
    
    config.database_url = "postgresql://user:pass@localhost/mydb"
    config.debug_mode = True
    config.api_keys = {"google": "abc123", "twitter": "xyz789"}
    
    print(f"Updated database URL: {config.database_url}")
    print(f"Updated debug mode: {config.debug_mode}")
    print(f"API keys: {config.api_keys}")
    
    # Uncommment to save to file
    # config.save()
    
    # URLBuilder example
    print("\nURLBuilder example:")
    builder = URLBuilder("https://api.example.com")
    
    builder.path = "/users/profile"
    builder.add_query_param("user_id", "12345")
    builder.add_query_param("format", "json")
    builder.fragment = "personal-info"
    
    print(f"Built URL: {builder.url}")
    
    builder.path = "/search"
    builder.clear_query_params()
    builder.add_query_param("q", "python properties")
    builder.fragment = ""
    
    print(f"New URL: {builder.url}")
    
    # WebPageAnalyzer example
    print("\nWebPageAnalyzer example:")
    html = """
    
    
    Test Page
    
        

Hello, World!

This is a test page with some text.

Link 1 Link 2 Image 1 Image 2 Image 3 """ analyzer = WebPageAnalyzer(html) # First access calculates the value print(f"Word count: {analyzer.word_count}") # Second access uses the cached value print(f"Word count (cached): {analyzer.word_count}") # Access other properties print(f"Link count: {analyzer.link_count}") print(f"Image count: {analyzer.image_count}") # Get all stats print(f"All stats: {analyzer.stats}") # JWTToken example print("\nJWTToken example:") # In a real implementation, this would be a valid JWT token jwt = JWTToken("header.payload.signature") print(f"Token: {jwt.token}") print(f"Header: {jwt.header}") print(f"Payload: {jwt.payload}") print(f"Subject: {jwt.subject}") print(f"Expiry: {jwt.expiry}") print(f"Is expired: {jwt.is_expired}") print(f"Time to expiry: {jwt.time_to_expiry}")

Code Breakdown:

Key Patterns in Real-World Use:

  1. Lazy Loading: Properties can delay expensive operations until data is actually needed.
  2. Caching: Properties can cache results to improve performance for expensive calculations.
  3. Computed Aggregates: Properties can combine multiple values into useful aggregates.
  4. Domain-Specific Validation: Properties can enforce domain rules specific to the application.
  5. Immutable Views: Properties can provide read-only views or defensive copies of internal data.
  6. Custom Descriptors: For advanced cases, custom descriptors like CachedProperty extend the property pattern.

Real-world analogy: These examples are like various smart home devices working together. The Config class is like a smart home hub that loads settings only when needed. The URLBuilder is like a smart navigation system that ensures all directions are valid. The CachedProperty is like a smart thermostat that remembers the last temperature reading to avoid constantly checking. The JWTToken is like a smart lock that handles all the security details while providing a simple interface.

Property Descriptors (Advanced)

The property decorator is actually built on a more general Python feature called descriptors. Understanding descriptors can help you create more advanced property-like objects. Here's a brief look at how descriptors work:

# Advanced: Understanding Descriptors

class TypedProperty:
    """A descriptor that enforces type checking"""
    
    def __init__(self, expected_type, doc=None):
        self.expected_type = expected_type
        self.__doc__ = doc
        self.name = None  # Will be set when the descriptor is accessed
    
    def __set_name__(self, owner, name):
        """Set the attribute name (Python 3.6+)"""
        self.name = name
    
    def __get__(self, instance, owner):
        """Called when the attribute is accessed"""
        if instance is None:
            return self
        
        # Retrieve the value from the instance's __dict__
        # Using a private attribute name to avoid conflicts
        return instance.__dict__.get("_" + self.name)
    
    def __set__(self, instance, value):
        """Called when the attribute is set"""
        if value is not None and not isinstance(value, self.expected_type):
            raise TypeError(f"{self.name} must be a {self.expected_type.__name__}")
        
        # Store the value in the instance's __dict__
        instance.__dict__["_" + self.name] = value


class Person:
    """A class using the TypedProperty descriptor"""
    
    name = TypedProperty(str, "The person's name")
    age = TypedProperty(int, "The person's age")
    
    def __init__(self, name, age):
        self.name = name
        self.age = age


# Demo the TypedProperty descriptor
person = Person("John", 30)
print(f"Name: {person.name}")
print(f"Age: {person.age}")

try:
    person.age = "thirty"  # Should raise TypeError
except TypeError as e:
    print(f"Error: {e}")

# Access the docstring
print(f"Docstring for name: {Person.name.__doc__}")

How Descriptors Work:

Descriptors are more advanced and typically not needed for everyday programming, but understanding them can help you create more powerful custom behaviors for attributes when needed.

Key Takeaways

Best Practices for Using Properties

  1. Start Simple: Begin with simple public attributes, and add properties only when you need validation, computation, or other special behavior.
  2. Use Consistent Naming: Store the actual data in a private attribute with an underscore prefix (e.g., _name) and use the unprefixed name for the property (e.g., name).
  3. Document Properties: Provide clear docstrings for properties, explaining their purpose, constraints, and whether they're read-only.
  4. Validate Input: Always validate input in setters to maintain data integrity and enforce business rules.
  5. Return Copies of Mutable Objects: In getters, return copies of mutable objects (lists, dictionaries, etc.) to prevent unintended modification.
  6. Keep Properties Lightweight: Properties should be relatively fast to compute. For expensive operations, consider caching or lazy loading.
  7. Maintain Backward Compatibility: When adding properties to existing code, ensure they behave the same way as the attributes they replace.
  8. Don't Modify State in Getters: Getters should not modify the object's state. They should be "pure" functions that simply return values.
  9. Prefer Properties over Getter/Setter Methods: In Python, properties are more idiomatic than explicit getter and setter methods.
  10. Consider Using Descriptors for Repeated Patterns: If you find yourself writing similar property patterns (like type checking) across multiple properties, consider using descriptors.

Assignment: Build a Course Management System with Properties

For today's assignment, you'll build a simple course management system that makes extensive use of property decorators for validation, computation, and encapsulation.

Requirements:

  1. Create a Student class with:
    • Properties for name, email, and ID with appropriate validation
    • A grades dictionary to store course grades
    • Computed properties for GPA and academic standing
  2. Create a Course class with:
    • Properties for course code, name, and instructor
    • A roster of enrolled students
    • Methods to add/remove students and assign grades
    • Computed properties for average grade and pass rate
  3. Create an Instructor class with:
    • Properties for name, email, and department
    • A list of courses taught
    • Computed properties for teaching load and student count
  4. Create a Department class with:
    • Properties for name, budget, and head (an Instructor)
    • Lists of courses and instructors
    • Computed properties for total enrollment and average course size
  5. Implement appropriate validation for all properties to ensure data integrity.
  6. Use computed properties for derived data to avoid redundancy and ensure consistency.
  7. Provide a simple command-line interface to demonstrate the system.

Bonus Challenges:

  1. Implement a custom property descriptor for common validation patterns.
  2. Add caching for expensive computed properties.
  3. Implement a simple file-based persistence system using properties for lazy loading.
  4. Create a Semester class that uses properties to manage temporal relationships between courses.
  5. Add a simple web API using Flask that exposes the properties in a RESTful manner.

Submit your work as a Python module with clear structure and organization. Be prepared to explain how your use of properties enhances the system's maintainability, usability, and data integrity.

Further Reading and Resources