Prerequisites
- Basic understanding of programming concepts ๐
- Python installation (3.8+) ๐
- VS Code or preferred IDE ๐ป
What you'll learn
- Understand the concept fundamentals ๐ฏ
- Apply the concept in real projects ๐๏ธ
- Debug common issues ๐
- Write clean, Pythonic code โจ
๐ฏ Introduction
Welcome to this exciting tutorial on Python descriptors! ๐ In this guide, weโll explore one of Pythonโs most powerful features for controlling attribute access.
Youโll discover how descriptors can transform your Python development experience. Whether youโre building frameworks ๐๏ธ, creating APIs ๐, or implementing advanced design patterns ๐จ, understanding descriptors is essential for writing sophisticated, maintainable code.
By the end of this tutorial, youโll feel confident using descriptors in your own projects! Letโs dive in! ๐โโ๏ธ
๐ Understanding Descriptors
๐ค What are Descriptors?
Descriptors are like magical gatekeepers ๐งโโ๏ธ for your object attributes. Think of them as smart properties that know how to get, set, and delete themselves!
In Python terms, a descriptor is any object that implements at least one of the descriptor protocol methods. This means you can:
- โจ Control how attributes are accessed
- ๐ Add validation and type checking automatically
- ๐ก๏ธ Create computed properties with caching
- ๐ฏ Implement lazy loading for performance
๐ก Why Use Descriptors?
Hereโs why developers love descriptors:
- Reusable Property Logic ๐: Write once, use everywhere
- Clean API Design ๐ป: Hide complex logic behind simple attributes
- Performance Control ๐: Lazy loading and caching built-in
- Framework Building ๐๏ธ: Essential for ORMs and form libraries
Real-world example: Imagine building a user profile system ๐ค. With descriptors, you can automatically validate email addresses, ensure age is positive, and cache expensive database lookups!
๐ง Basic Syntax and Usage
๐ Simple Descriptor Example
Letโs start with a friendly example:
# ๐ Hello, Descriptors!
class PositiveNumber:
"""A descriptor that only accepts positive numbers ๐ฏ"""
def __init__(self, name):
self.name = name # ๐ Store the attribute name
def __get__(self, obj, objtype=None):
# ๐จ Called when accessing the attribute
if obj is None:
return self
return obj.__dict__.get(self.name, 0)
def __set__(self, obj, value):
# ๐ก๏ธ Called when setting the attribute
if value < 0:
raise ValueError(f"{self.name} must be positive! Got {value} ๐ฑ")
obj.__dict__[self.name] = value
def __delete__(self, obj):
# ๐๏ธ Called when deleting the attribute
print(f"Deleting {self.name} ๐")
del obj.__dict__[self.name]
# ๐ฎ Using our descriptor
class Player:
health = PositiveNumber('health') # ๐ Player health
score = PositiveNumber('score') # ๐ Player score
def __init__(self, name):
self.name = name # ๐ค Player name
๐ก Explanation: Notice how we use the descriptor protocol methods! The descriptor automatically validates that health and score are always positive.
๐ฏ Common Descriptor Patterns
Here are patterns youโll use daily:
# ๐๏ธ Pattern 1: Type-checked attributes
class TypedAttribute:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(f"{self.name} must be {self.expected_type.__name__}! ๐ฐ")
obj.__dict__[self.name] = value
# ๐จ Pattern 2: Lazy computation
class LazyProperty:
def __init__(self, function):
self.function = function
self.name = function.__name__
def __get__(self, obj, objtype=None):
if obj is None:
return self
# ๐ก Compute once, cache forever!
value = obj.__dict__[self.name] = self.function(obj)
return value
# ๐ Pattern 3: Property with history
class HistoryAttribute:
def __init__(self, name):
self.name = name
self.history_name = f'_{name}_history'
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.name, None)
def __set__(self, obj, value):
# ๐ Keep track of all values
if not hasattr(obj, self.history_name):
setattr(obj, self.history_name, [])
getattr(obj, self.history_name).append(value)
setattr(obj, f'_{self.name}', value)
๐ก Practical Examples
๐ Example 1: E-commerce Product System
Letโs build something real:
# ๐๏ธ Advanced product management system
class PriceDescriptor:
"""Manages product pricing with validation and discounts ๐ฐ"""
def __init__(self):
self.name = '_price'
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.name, 0)
def __set__(self, obj, value):
if value < 0:
raise ValueError("Price can't be negative! ๐ฑ")
setattr(obj, self.name, value)
# ๐ฏ Auto-calculate discounted price
if hasattr(obj, 'discount'):
obj._discounted_price = value * (1 - obj.discount)
class StockDescriptor:
"""Tracks inventory with alerts ๐ฆ"""
def __init__(self, low_stock_threshold=10):
self.threshold = low_stock_threshold
self.name = '_stock'
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.name, 0)
def __set__(self, obj, value):
if value < 0:
raise ValueError("Stock can't be negative! ๐ซ")
# โ ๏ธ Low stock alert
if value < self.threshold and value > 0:
print(f"โ ๏ธ Low stock alert for {obj.name}! Only {value} left!")
elif value == 0:
print(f"๐จ {obj.name} is OUT OF STOCK!")
setattr(obj, self.name, value)
# ๐ Product class using descriptors
class Product:
price = PriceDescriptor()
stock = StockDescriptor(low_stock_threshold=5)
def __init__(self, name, price, stock, discount=0):
self.name = name # ๐ Product name
self.discount = discount # ๐ท๏ธ Discount percentage
self.price = price # ๐ฐ Product price
self.stock = stock # ๐ฆ Available stock
@LazyProperty
def description(self):
# ๐จ Expensive operation - only computed once!
print("๐ Generating product description...")
return f"Amazing {self.name} - Best quality guaranteed! โญ"
def sell(self, quantity=1):
"""Sell the product ๐๏ธ"""
if self.stock >= quantity:
self.stock -= quantity
total = self.price * quantity * (1 - self.discount)
print(f"๐ฐ Sold {quantity} {self.name}(s) for ${total:.2f}!")
return True
else:
print(f"๐ Sorry, only {self.stock} {self.name}(s) available!")
return False
# ๐ฎ Let's use it!
laptop = Product("Gaming Laptop", 1299.99, 15, discount=0.1)
laptop.sell(5) # ๐ฐ Sold 5 Gaming Laptop(s) for $5849.96!
laptop.sell(8) # โ ๏ธ Low stock alert! Only 2 left!
laptop.sell(3) # ๐ Sorry, only 2 Gaming Laptop(s) available!
๐ฏ Try it yourself: Add a RatingDescriptor
that validates ratings between 1-5 stars!
๐ฎ Example 2: Game Character Stats
Letโs make it fun with a game character system:
# ๐ Advanced game character stats system
class BoundedStat:
"""A stat with min/max bounds ๐"""
def __init__(self, min_value=0, max_value=100, name=None):
self.min_value = min_value
self.max_value = max_value
self.name = name
def __set_name__(self, owner, name):
# ๐ฏ Auto-set the name if not provided
if self.name is None:
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(f'_{self.name}', self.min_value)
def __set__(self, obj, value):
# ๐ก๏ธ Enforce bounds
value = max(self.min_value, min(value, self.max_value))
obj.__dict__[f'_{self.name}'] = value
# ๐ Check for level up conditions
if self.name == 'experience' and value >= self.max_value:
print(f"๐ LEVEL UP! {obj.name} reached a new level!")
obj.level_up()
class DependentStat:
"""A stat that depends on other stats ๐"""
def __init__(self, calculation):
self.calculation = calculation
def __get__(self, obj, objtype=None):
if obj is None:
return self
# ๐ฏ Calculate on the fly
return self.calculation(obj)
# ๐ฎ Game character class
class GameCharacter:
health = BoundedStat(0, 100) # ๐ Health: 0-100
mana = BoundedStat(0, 100) # ๐ Mana: 0-100
experience = BoundedStat(0, 1000) # โญ XP: 0-1000
strength = BoundedStat(1, 50) # ๐ช Strength: 1-50
# ๐ Computed stats
@property
def power(self):
"""Calculate total power ๐"""
return DependentStat(lambda obj: obj.strength * 2 + obj.level * 10).__get__(self)
def __init__(self, name, character_class):
self.name = name # ๐ค Character name
self.character_class = character_class # ๐ก๏ธ Class (warrior, mage, etc.)
self.level = 1 # ๐ Current level
self._skills = [] # ๐ฏ Learned skills
# ๐จ Set initial stats based on class
if character_class == "warrior":
self.health = 100
self.strength = 15
self.mana = 30
elif character_class == "mage":
self.health = 70
self.strength = 5
self.mana = 100
def level_up(self):
"""Level up the character ๐"""
self.level += 1
self.experience = 0 # Reset XP
self.health = 100 # Full heal!
self.mana = 100 # Full mana!
self.strength += 2 # Get stronger!
print(f"โจ {self.name} is now level {self.level}!")
def take_damage(self, damage):
"""Take damage ๐ฅ"""
self.health -= damage
if self.health <= 0:
print(f"๐ต {self.name} has been defeated!")
else:
print(f"๐ {self.name} took {damage} damage! Health: {self.health}")
# ๐ฎ Let's play!
hero = GameCharacter("Aragorn", "warrior")
print(f"โ๏ธ {hero.name} the {hero.character_class} enters the game!")
print(f"๐ช Power level: {hero.power}")
hero.experience = 999 # Almost there...
hero.experience = 1000 # ๐ LEVEL UP!
print(f"๐ช New power level: {hero.power}")
๐ Advanced Concepts
๐งโโ๏ธ Advanced Topic 1: Data Descriptors vs Non-Data Descriptors
When youโre ready to level up, understand the descriptor hierarchy:
# ๐ฏ Data descriptor (has __set__ and/or __delete__)
class DataDescriptor:
"""Takes precedence over instance dictionary ๐ช"""
def __init__(self, initial_value=None):
self.value = initial_value
def __get__(self, obj, objtype=None):
print("๐ฏ Data descriptor __get__ called")
return self.value
def __set__(self, obj, value):
print("โจ Data descriptor __set__ called")
self.value = value
# ๐ช Non-data descriptor (only has __get__)
class NonDataDescriptor:
"""Instance dictionary takes precedence ๐จ"""
def __get__(self, obj, objtype=None):
print("๐จ Non-data descriptor __get__ called")
return "I'm a non-data descriptor!"
# ๐ฌ Testing precedence
class TestClass:
data_attr = DataDescriptor("data")
non_data_attr = NonDataDescriptor()
obj = TestClass()
# Data descriptor always wins!
obj.data_attr = "new value" # โจ Uses descriptor's __set__
print(obj.data_attr) # ๐ฏ Uses descriptor's __get__
# Instance dict can override non-data descriptor
obj.__dict__['non_data_attr'] = "instance value"
print(obj.non_data_attr) # Uses instance dict, not descriptor!
๐๏ธ Advanced Topic 2: Building a Mini-ORM with Descriptors
For the brave developers, letโs build a simple ORM:
# ๐ Database field descriptors
class Field:
"""Base field descriptor for our ORM ๐ฆ"""
def __init__(self, field_type, required=True, default=None):
self.field_type = field_type
self.required = required
self.default = default
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name, self.default)
def __set__(self, obj, value):
if value is None and self.required:
raise ValueError(f"{self.name} is required! ๐จ")
if value is not None and not isinstance(value, self.field_type):
raise TypeError(f"{self.name} must be {self.field_type.__name__}! ๐ฐ")
obj.__dict__[self.name] = value
class CharField(Field):
"""String field with max length ๐"""
def __init__(self, max_length=255, **kwargs):
super().__init__(str, **kwargs)
self.max_length = max_length
def __set__(self, obj, value):
super().__set__(obj, value)
if value and len(value) > self.max_length:
raise ValueError(f"{self.name} exceeds max length of {self.max_length}! ๐")
class IntegerField(Field):
"""Integer field with optional bounds ๐ข"""
def __init__(self, min_value=None, max_value=None, **kwargs):
super().__init__(int, **kwargs)
self.min_value = min_value
self.max_value = max_value
def __set__(self, obj, value):
super().__set__(obj, value)
if value is not None:
if self.min_value is not None and value < self.min_value:
raise ValueError(f"{self.name} must be >= {self.min_value}! ๐")
if self.max_value is not None and value > self.max_value:
raise ValueError(f"{self.name} must be <= {self.max_value}! ๐")
# ๐๏ธ Model base class
class Model:
"""Base class for all models ๐ฏ"""
def __init__(self, **kwargs):
# ๐จ Initialize all fields
for key, value in kwargs.items():
setattr(self, key, value)
def save(self):
"""Save to database (simulated) ๐พ"""
print(f"๐พ Saving {self.__class__.__name__} to database...")
for name, field in self.__class__.__dict__.items():
if isinstance(field, Field):
value = getattr(self, name)
print(f" ๐ {name}: {value}")
print("โ
Saved successfully!")
# ๐ฎ Using our mini-ORM
class User(Model):
username = CharField(max_length=50, required=True)
email = CharField(max_length=100, required=True)
age = IntegerField(min_value=0, max_value=150, required=False)
score = IntegerField(default=0)
# Let's create a user!
user = User(username="PythonMaster", email="[email protected]", age=25)
user.save() # ๐พ Saves to database!
โ ๏ธ Common Pitfalls and Solutions
๐ฑ Pitfall 1: Forgetting set_name
# โ Wrong way - manually tracking names
class BadDescriptor:
def __init__(self, name):
self.name = name # Have to pass name manually ๐ฐ
def __get__(self, obj, objtype=None):
return obj.__dict__.get(self.name)
class MyClass:
attr = BadDescriptor('attr') # Redundant! ๐ค
# โ
Correct way - use __set_name__
class GoodDescriptor:
def __set_name__(self, owner, name):
self.name = name # Automatically set! ๐ฏ
def __get__(self, obj, objtype=None):
return obj.__dict__.get(self.name)
class MyClass:
attr = GoodDescriptor() # Clean! โจ
๐คฏ Pitfall 2: Infinite Recursion
# โ Dangerous - infinite recursion!
class BadDescriptor:
def __get__(self, obj, objtype=None):
return obj.value # ๐ฅ This calls __get__ again!
def __set__(self, obj, value):
obj.value = value # ๐ฅ This calls __set__ again!
# โ
Safe - use __dict__ or different attribute
class GoodDescriptor:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get('_value', None) # โ
Direct dict access
def __set__(self, obj, value):
obj.__dict__['_value'] = value # โ
Safe!
๐ ๏ธ Best Practices
- ๐ฏ Use set_name: Let Python handle name tracking for you
- ๐ Handle obj is None: Return self for class-level access
- ๐ก๏ธ Validate in set: Keep your data clean and safe
- ๐จ Document Behavior: Descriptors can be magical - explain them!
- โจ Consider @property First: Sometimes a simple property is enough
๐งช Hands-On Exercise
๐ฏ Challenge: Build a Validation Framework
Create a comprehensive validation system using descriptors:
๐ Requirements:
- โ Email validator with regex checking
- ๐ท๏ธ Phone number validator with format checking
- ๐ค Age validator with realistic bounds
- ๐ Date validator ensuring dates arenโt in the future
- ๐จ Custom validator support
๐ Bonus Points:
- Add validation error messages
- Support multiple validators per field
- Create a decorator for easy validation
๐ก Solution
๐ Click to see solution
import re
from datetime import date
# ๐ฏ Base validator descriptor
class Validator:
"""Base class for all validators ๐ก๏ธ"""
def __init__(self, *validators, error_message=None):
self.validators = validators
self.error_message = error_message
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(f'_{self.name}')
def __set__(self, obj, value):
# ๐ Run all validators
for validator in self.validators:
if not validator(value):
error = self.error_message or f"Validation failed for {self.name}"
raise ValueError(f"โ {error}")
obj.__dict__[f'_{self.name}'] = value
print(f"โ
{self.name} validated successfully!")
# ๐จ Custom validators
def email_validator(value):
"""Validate email format ๐ง"""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(pattern, value))
def phone_validator(value):
"""Validate phone format ๐ฑ"""
# Accepts: 123-456-7890, (123) 456-7890, 123.456.7890
pattern = r'^[\d\s\-\.\(\)]+$'
cleaned = re.sub(r'[\s\-\.\(\)]', '', value)
return bool(re.match(pattern, value)) and len(cleaned) == 10
def age_validator(value):
"""Validate age is reasonable ๐"""
return isinstance(value, int) and 0 <= value <= 150
def not_future_date(value):
"""Ensure date isn't in the future ๐
"""
return isinstance(value, date) and value <= date.today()
# ๐๏ธ Specialized validators
class EmailField(Validator):
def __init__(self):
super().__init__(
email_validator,
error_message="Invalid email format! Example: [email protected]"
)
class PhoneField(Validator):
def __init__(self):
super().__init__(
phone_validator,
error_message="Invalid phone! Use format: 123-456-7890"
)
class AgeField(Validator):
def __init__(self):
super().__init__(
age_validator,
error_message="Age must be between 0 and 150!"
)
class DateField(Validator):
def __init__(self, not_future=True):
validators = []
if not_future:
validators.append(not_future_date)
super().__init__(
*validators,
error_message="Date cannot be in the future!"
)
# ๐ฎ User registration form
class UserRegistration:
email = EmailField()
phone = PhoneField()
age = AgeField()
birthdate = DateField(not_future=True)
def __init__(self, username):
self.username = username # ๐ค No validation needed
def register(self):
"""Complete registration ๐"""
print(f"\n๐ Registration successful for {self.username}!")
print(f"๐ง Email: {self.email}")
print(f"๐ฑ Phone: {self.phone}")
print(f"๐ Age: {self.age}")
print(f"๐
Birthdate: {self.birthdate}")
# ๐ฎ Test our validation system!
try:
user = UserRegistration("PythonPro")
# Valid data
user.email = "[email protected]" # โ
Email validated!
user.phone = "123-456-7890" # โ
Phone validated!
user.age = 25 # โ
Age validated!
user.birthdate = date(1998, 5, 15) # โ
Birthdate validated!
user.register()
# Invalid data tests
try:
user.email = "bad-email" # โ Invalid!
except ValueError as e:
print(e)
try:
user.phone = "123" # โ Too short!
except ValueError as e:
print(e)
try:
user.age = 200 # โ Too old!
except ValueError as e:
print(e)
except ValueError as e:
print(f"Registration failed: {e}")
# ๐ Bonus: Decorator for validation
def validated_class(cls):
"""Add validation to all descriptor fields ๐ฏ"""
original_init = cls.__init__
def new_init(self, **kwargs):
original_init(self)
for key, value in kwargs.items():
setattr(self, key, value)
cls.__init__ = new_init
return cls
@validated_class
class Product:
name = Validator(lambda x: len(x) > 0, error_message="Name required!")
price = Validator(lambda x: x > 0, error_message="Price must be positive!")
stock = Validator(lambda x: x >= 0, error_message="Stock can't be negative!")
# Easy to use!
product = Product(name="Python Book", price=29.99, stock=100)
print(f"\n๐ฆ Created product: {product.name}")
๐ Key Takeaways
Youโve learned so much! Hereโs what you can now do:
- โ Create descriptors with confidence ๐ช
- โ Control attribute access like a Python pro ๐ก๏ธ
- โ Build reusable property logic for cleaner code ๐ฏ
- โ Implement advanced patterns like ORMs and validators ๐
- โ Avoid common descriptor pitfalls with ease! ๐
Remember: Descriptors are powerful tools that make Pythonโs magic possible. Theyโre behind properties, methods, and many framework features! ๐ค
๐ค Next Steps
Congratulations! ๐ Youโve mastered Python descriptors!
Hereโs what to do next:
- ๐ป Practice with the validation framework exercise above
- ๐๏ธ Build a caching descriptor for expensive computations
- ๐ Explore how Django and SQLAlchemy use descriptors
- ๐ Share your descriptor creations with the Python community!
Remember: Every Python expert started where you are now. Keep coding, keep learning, and most importantly, have fun with descriptors! ๐
Happy coding! ๐๐โจ