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:
PersonTraditionaluses the traditional getter/setter method approach:- Attributes are prefixed with an underscore to indicate they're intended to be private
- Explicit getter methods (
get_first_name, etc.) to access attributes - Explicit setter methods (
set_first_name, etc.) for modifying attributes with validation - Additional methods for derived data (
get_full_name)
PersonPropertyuses theproperty()function to create properties:- Same private attributes with underscore prefix
- Private getter and setter methods (
_get_first_name,_set_first_name, etc.) - Property objects created using
property(getter, setter, deleter, doc) - This allows attribute-like access that actually calls the getter and setter methods
- Read-only properties (like
full_name) defined by omitting the setter
Advantages of Properties:
- Cleaner Syntax: Object users can use simple attribute access syntax (
person.name) instead of method calls (person.get_name()) - Encapsulation: You can still control access, validation, and computation behind the scenes
- Backward Compatibility: You can start with a simple attribute and later add a property without changing the interface
- Documentation: Properties can have docstrings, making them self-documenting
- Read-Only Attributes: You can create read-only attributes by defining only a getter
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:
Personclass uses property decorators:@propertycreates a read-only property (the getter)@name.setterdefines the setter method for the property- Properties like
full_nameandis_adultare read-only (no setter)
Rectangleclass shows more examples:- Computed properties for
area,perimeter, andis_square - Validation in property setters
- Deleter implementations with
@name.deleter
- Computed properties for
Decorator Syntax Explained:
@property- This creates a property getter. It's placed above a method that retrieves the property value.@name.setter- This creates a property setter. The method name must be the same as the getter, and it takes a value parameter.@name.deleter- This creates a property deleter, which runs whendel obj.nameis called.
Property Decorator Order:
The order of property decorators is important:
- Define the getter first with
@property - Define the setter with
@name.setter(if needed) - 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:
Circleclass:- Computed properties for
diameter,area, andcircumference - A setter for
diameterthat updatesradius
- Computed properties for
Personclass:- Computed properties for
full_name,age, andinitials - Complex calculation for
agebased onbirth_date
- Computed properties for
Temperatureclass:- Properties for different temperature units (Celsius, Fahrenheit, Kelvin)
- Setters that convert between units
- Validation to prevent physically impossible values
Key Concepts for Computed Properties:
- Lazy Evaluation: Computed properties calculate their values only when accessed, which is more efficient than calculating everything upfront.
- Derived Data: Computed properties can derive their values from other attributes, keeping your data model DRY (Don't Repeat Yourself).
- Bidirectional Relationships: Properties can have setters that update other related properties, maintaining consistency.
- Caching: For expensive calculations, you might cache the result and only recalculate when dependencies change (not shown in examples).
- 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:
Userclass:- Comprehensive validation for username, email, password, and birth_date
- Type checking before value validation
- Pattern-based validation using regular expressions
- Password security considerations (masking in getter, complexity validation)
- Optional field handling (birth_date can be None)
BankAccountclass:- Financial data validation patterns
- Defensive copying for collections (transaction_history)
- Business logic validation in methods (deposit, withdraw)
- Transaction recording with history
Validation Patterns:
- Type Checking: Use
isinstance()to ensure values are of the expected type. - Value Validation: Check that values meet domain-specific requirements (length, pattern, range, etc.).
- Pattern Matching: Use regular expressions for complex pattern validation (username, email, account numbers).
- Business Rules: Enforce domain-specific business rules (birth date must be in the past, balance can't be negative).
- Defensive Copying: Return copies of mutable objects to prevent external modification.
- Data Privacy: Hide sensitive information in getters (password masking).
Initialization Pattern:
Note the pattern used in both classes:
- Initialize private attributes to None in
__init__ - Then set attributes through properties to trigger validation
- 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
"""
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:
Configclass:- Uses properties for lazy loading configuration from a file
- Type validation for configuration values
- Default values for missing configuration
- Defensive copying for mutable values (api_keys)
URLBuilderclass:- Uses properties to build and validate URL components
- Computed
urlproperty that combines all components - Type and format validation
CachedPropertyclass:- A property descriptor that caches the result of expensive calculations
- Used in
WebPageAnalyzerto avoid recalculating values
WebPageAnalyzerclass:- Uses the
CachedPropertydescriptor for expensive calculations - Regular property (
stats) that combines other properties
- Uses the
JWTTokenclass:- Properties for accessing JWT token components
- Computed properties for token state (
is_expired,time_to_expiry) - Token parsing and validation
Key Patterns in Real-World Use:
- Lazy Loading: Properties can delay expensive operations until data is actually needed.
- Caching: Properties can cache results to improve performance for expensive calculations.
- Computed Aggregates: Properties can combine multiple values into useful aggregates.
- Domain-Specific Validation: Properties can enforce domain rules specific to the application.
- Immutable Views: Properties can provide read-only views or defensive copies of internal data.
- Custom Descriptors: For advanced cases, custom descriptors like
CachedPropertyextend 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:
- A descriptor is any object that implements at least one of the methods
__get__,__set__, or__delete__. - When you access, set, or delete an attribute that is a descriptor, these methods are called instead of the normal attribute operations.
- The property decorator creates a descriptor that uses these methods to call your getter, setter, and deleter functions.
- Descriptors are a powerful feature that enables many of Python's advanced features, including properties, class methods, static methods, and more.
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
- Property Decorators in Python provide a clean, Pythonic way to implement getters, setters, and deleters for class attributes.
- Encapsulation is achieved by using private attributes (prefixed with
_) and providing controlled access through properties. - Validation can be performed in property setters to ensure data integrity and enforce business rules.
- Computed Properties calculate their values on-the-fly based on other attributes, avoiding data duplication and ensuring consistency.
- Backward Compatibility is preserved when refactoring—you can start with simple attributes and later add properties without changing the interface.
- Interface Simplicity is maintained by allowing attribute-like access (
obj.attr) instead of method calls (obj.get_attr()). - Read-Only Properties can be created by implementing only a getter method (no setter).
- Lazy Loading can be implemented using properties to delay expensive operations until actually needed.
- Advanced Patterns like caching, type checking, and custom descriptors extend the property pattern for specialized needs.
Best Practices for Using Properties
- Start Simple: Begin with simple public attributes, and add properties only when you need validation, computation, or other special behavior.
- 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). - Document Properties: Provide clear docstrings for properties, explaining their purpose, constraints, and whether they're read-only.
- Validate Input: Always validate input in setters to maintain data integrity and enforce business rules.
- Return Copies of Mutable Objects: In getters, return copies of mutable objects (lists, dictionaries, etc.) to prevent unintended modification.
- Keep Properties Lightweight: Properties should be relatively fast to compute. For expensive operations, consider caching or lazy loading.
- Maintain Backward Compatibility: When adding properties to existing code, ensure they behave the same way as the attributes they replace.
- Don't Modify State in Getters: Getters should not modify the object's state. They should be "pure" functions that simply return values.
- Prefer Properties over Getter/Setter Methods: In Python, properties are more idiomatic than explicit getter and setter methods.
- 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:
- Create a
Studentclass with:- Properties for name, email, and ID with appropriate validation
- A grades dictionary to store course grades
- Computed properties for GPA and academic standing
- Create a
Courseclass 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
- Create an
Instructorclass with:- Properties for name, email, and department
- A list of courses taught
- Computed properties for teaching load and student count
- Create a
Departmentclass with:- Properties for name, budget, and head (an Instructor)
- Lists of courses and instructors
- Computed properties for total enrollment and average course size
- Implement appropriate validation for all properties to ensure data integrity.
- Use computed properties for derived data to avoid redundancy and ensure consistency.
- Provide a simple command-line interface to demonstrate the system.
Bonus Challenges:
- Implement a custom property descriptor for common validation patterns.
- Add caching for expensive computed properties.
- Implement a simple file-based persistence system using properties for lazy loading.
- Create a
Semesterclass that uses properties to manage temporal relationships between courses. - 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
- Python Documentation: property()
- Python Documentation: Descriptor HowTo Guide
- Real Python: Python's property(): Add Managed Attributes to Your Classes
- Real Python: Python Descriptors: An Introduction
- Fluent Python by Luciano Ramalho (Chapter 19: Properties, Attributes, and Descriptors)
- Python Cookbook, 3rd Edition by David Beazley and Brian K. Jones (Chapter 8: Classes and Objects)
- Effective Python: 90 Specific Ways to Write Better Python by Brett Slatkin (Item 44: Use Plain Attributes Instead of Getter and Setter Methods)