Timothy had used properties extensively—adding validation, computing values, maintaining encapsulation. But he'd never questioned how @property actually worked. When he wrote book.title, how did Python know to call a method instead of accessing an attribute? What was the mechanism behind this seemingly magical behavior?
class Book:
def __init__(self, title, pages):
self._title = title
self._pages = pages
@property
def title(self):
return self._title
@property
def pages(self):
return self._pages
@pages.setter
def pages(self, value):
if value < 0:
raise ValueError("Pages cannot be negative")
self._pages = value
book = Book("Dune", 412)
print(book.pages) # Calls the getter - but how?
book.pages = 500 # Calls the setter - but how?
Margaret found him staring at property decorators, trying to understand the mechanism. "You're ready for the deepest workshop," she said. "Come to the Descriptor Workshop—where we reveal how properties, class methods, static methods, and attribute access actually work."
The Descriptor Protocol
Margaret showed Timothy Python's attribute access protocol:
class Descriptor:
"""A descriptor controls attribute access"""
def __get__(self, obj, objtype=None):
"""Called when attribute is accessed"""
print(f"__get__ called: obj={obj}, objtype={objtype}")
return "descriptor value"
def __set__(self, obj, value):
"""Called when attribute is assigned"""
print(f"__set__ called: obj={obj}, value={value}")
def __delete__(self, obj):
"""Called when attribute is deleted"""
print(f"__delete__ called: obj={obj}")
class Book:
# Descriptor as a class attribute
title = Descriptor()
book = Book()
# Access triggers __get__
print(book.title)
# __get__ called: obj=<Book object>, objtype=<class 'Book'>
# descriptor value
# Assignment triggers __set__
book.title = "Dune"
# __set__ called: obj=<Book object>, value=Dune
# Deletion triggers __delete__
del book.title
# __delete__ called: obj=<Book object>
"This is the descriptor protocol," Margaret explained. "When Python accesses an attribute, it checks if the attribute is a descriptor—an object with __get__, __set__, or __delete__ methods. If so, Python calls those methods instead of normal attribute access. This is how properties work."
The obj is None Pattern
Timothy learned why descriptors checked if obj was None:
class Descriptor:
def __get__(self, obj, objtype=None):
if obj is None:
# Accessed from class, not instance
print("Accessed from class")
return self # Return descriptor itself
# Accessed from instance
print(f"Accessed from instance: {obj}")
return "instance value"
class Book:
attr = Descriptor()
# Access from class - obj is None
descriptor = Book.attr # "Accessed from class"
print(type(descriptor)) # <class 'Descriptor'>
# Access from instance - obj is the instance
book = Book()
value = book.attr # "Accessed from instance: <Book object>"
print(value) # "instance value"
"When you access a descriptor from the class itself, obj is None," Margaret explained. "This lets you introspect the descriptor. When accessed from an instance, obj is that instance. This pattern is crucial for tools and frameworks that need to examine class structure."
Descriptors Must Be Class Attributes
Margaret emphasized a critical limitation:
class Descriptor:
def __get__(self, obj, objtype=None):
return "descriptor value"
class Book:
class_attr = Descriptor() # Works - class attribute
book = Book()
# Access from instance works
print(book.class_attr) # "descriptor value" - __get__ called!
# But descriptors in instance __dict__ don't work!
book.instance_attr = Descriptor() # Stored as regular object
print(book.instance_attr) # <Descriptor object> - __get__ NOT called!
# Descriptors MUST be class attributes to work
# They don't work in instance __dict__
"Descriptors only work when they're class attributes," Margaret cautioned. "If you put a descriptor in an instance's __dict__, Python treats it as a regular object. The descriptor protocol only activates for class-level attributes."
How Properties Are Descriptors
Timothy discovered properties were just descriptors:
# This property:
class Book:
@property
def title(self):
return self._title
# Is equivalent to:
class Book:
def get_title(self):
return self._title
title = property(get_title) # property is a descriptor!
# property() creates a descriptor object with __get__, __set__, __delete__
"The property() built-in is a descriptor," Margaret explained. "When you use @property, you're creating a descriptor that calls your method when the attribute is accessed."
Building a Validation Descriptor
Margaret showed Timothy a practical descriptor:
class PositiveInteger:
"""Descriptor that enforces positive integers"""
def __init__(self, name):
self.name = name
self.private_name = f'_{name}'
def __get__(self, obj, objtype=None):
if obj is None:
return self # Access from class, not instance
return getattr(obj, self.private_name)
def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError(f"{self.name} must be an integer")
if value < 0:
raise ValueError(f"{self.name} must be positive")
setattr(obj, self.private_name, value)
class Book:
pages = PositiveInteger("pages")
year = PositiveInteger("year")
def __init__(self, title, pages, year):
self.title = title
self.pages = pages # Triggers __set__
self.year = year # Triggers __set__
# Validation happens automatically
book = Book("Dune", 412, 1965)
print(book.pages) # 412 - triggers __get__
# Validation catches errors
try:
book.pages = -100 # Triggers __set__ with validation
except ValueError as e:
print(e) # "pages must be positive"
try:
book.year = "nineteen sixty-five"
except TypeError as e:
print(e) # "year must be an integer"
"Descriptors let you reuse validation logic," Margaret explained. "Define the descriptor once, use it on multiple attributes. Each attribute gets the same validation automatically."
Data Descriptors vs Non-Data Descriptors
Timothy learned about descriptor precedence:
class DataDescriptor:
"""Has __get__ AND __set__ - data descriptor"""
def __get__(self, obj, objtype=None):
return "data descriptor"
def __set__(self, obj, value):
pass
class NonDataDescriptor:
"""Has only __get__ - non-data descriptor"""
def __get__(self, obj, objtype=None):
return "non-data descriptor"
class Example:
data_desc = DataDescriptor()
non_data_desc = NonDataDescriptor()
example = Example()
# Data descriptor takes precedence over instance __dict__
example.__dict__['data_desc'] = "instance value"
print(example.data_desc) # "data descriptor" - descriptor wins!
# Non-data descriptor is overridden by instance __dict__
example.__dict__['non_data_desc'] = "instance value"
print(example.non_data_desc) # "instance value" - instance wins!
"Descriptors with __set__ are data descriptors," Margaret explained. "They override instance dictionaries. Descriptors with only __get__ are non-data descriptors—instance attributes can shadow them. This is why you can override class methods by setting instance attributes."
The set_name Method
Margaret showed Timothy Python 3.6's descriptor enhancement:
class TypedAttribute:
"""Descriptor that knows its own name"""
def __set_name__(self, owner, name):
"""Called when descriptor is assigned to a class attribute"""
self.name = name
self.private_name = f'_{name}'
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name)
def __set__(self, obj, value):
# Validation using the name
if not isinstance(value, str):
raise TypeError(f"{self.name} must be a string")
setattr(obj, self.private_name, value)
class Book:
title = TypedAttribute() # __set_name__ called with name="title"
author = TypedAttribute() # __set_name__ called with name="author"
def __init__(self, title, author):
self.title = title
self.author = author
book = Book("Dune", "Herbert")
print(book.title) # Works - descriptor knows its name
"The __set_name__ method is called when the descriptor is assigned to a class," Margaret explained. "It receives the owner class and the attribute name. No more manual name passing!"
How Classmethods Work
Timothy discovered classmethods were descriptors:
# Simplified classmethod implementation
class ClassMethod:
"""Descriptor that implements @classmethod behavior"""
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
# Return a bound method - always pass the class
if objtype is None:
objtype = type(obj)
def wrapper(*args, **kwargs):
return self.func(objtype, *args, **kwargs)
return wrapper
class Book:
@ClassMethod # Our descriptor, not built-in
def from_csv(cls, csv_string):
parts = csv_string.split(',')
return cls(parts[0], parts[1])
# When you access Book.from_csv, __get__ is called
# It returns a function that passes Book as first argument
book = Book.from_csv("Dune,Herbert")
"Classmethods are descriptors," Margaret explained. "The __get__ method returns a function that injects the class as the first argument. That's how cls magically appears."
How Staticmethods Work
Margaret showed Timothy staticmethods were even simpler:
# Simplified staticmethod implementation
class StaticMethod:
"""Descriptor that implements @staticmethod behavior"""
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
# Just return the function - no binding
return self.func
class Book:
@StaticMethod # Our descriptor, not built-in
def is_valid_isbn(isbn):
return len(isbn) == 13
# When you access Book.is_valid_isbn, __get__ is called
# It just returns the original function unchanged
print(Book.is_valid_isbn("9780441013593"))
"Staticmethods are the simplest descriptors," Margaret noted. "They just return the original function without any modification. No self, no cls—just the raw function."
Descriptors for Type Checking
Timothy learned descriptors could enforce types:
class TypedField:
"""Descriptor that enforces type"""
def __init__(self, field_type):
self.field_type = field_type
def __set_name__(self, owner, name):
self.name = name
self.private_name = f'_{name}'
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
if not isinstance(value, self.field_type):
raise TypeError(
f"{self.name} must be {self.field_type.__name__}, "
f"got {type(value).__name__}"
)
setattr(obj, self.private_name, value)
class Book:
title = TypedField(str)
pages = TypedField(int)
published = TypedField(bool)
def __init__(self, title, pages, published):
self.title = title
self.pages = pages
self.published = published
book = Book("Dune", 412, True)
try:
book.pages = "four hundred twelve" # Wrong type!
except TypeError as e:
print(e) # "pages must be int, got str"
Descriptors with Caching
Margaret demonstrated caching expensive computations:
class CachedProperty:
"""Descriptor that caches computed values"""
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Check if cached value exists
cache_attr = f'_cached_{self.name}'
if not hasattr(obj, cache_attr):
# Compute and cache
value = self.func(obj)
setattr(obj, cache_attr, value)
return getattr(obj, cache_attr)
class Book:
def __init__(self, title, pages):
self.title = title
self.pages = pages
@CachedProperty
def reading_time(self):
print("Computing reading time...")
return self.pages * 2 # 2 minutes per page
book = Book("Dune", 412)
print(book.reading_time) # "Computing reading time..." then 824
print(book.reading_time) # 824 - cached, no computation
"This is what functools.cached_property does," Margaret explained. "The descriptor computes the value once, caches it, and returns the cached value on subsequent access."
Using functools.cached_property
Timothy learned Python's standard library provided this pattern:
from functools import cached_property
class Book:
def __init__(self, title, pages):
self.title = title
self.pages = pages
@cached_property
def reading_time(self):
print("Computing reading time...")
return self.pages * 2
book = Book("Dune", 412)
print(book.reading_time) # Computing reading time... 824
print(book.reading_time) # 824 (cached, no computation)
# Under the hood, it's a descriptor!
# It stores the cached value in the instance __dict__
# After first access, instance dict has the value
print(book.__dict__) # {'title': 'Dune', 'pages': 412, 'reading_time': 824}
"Python's cached_property is a non-data descriptor," Margaret noted. "After first access, it stores the result in the instance dictionary. Since it's a non-data descriptor (only __get__, no __set__), the instance value takes precedence on subsequent access—no descriptor call needed. This is faster than calling __get__ every time."
Proper Per-Instance Storage with WeakRef
Margaret showed Timothy the production pattern for descriptors:
import weakref
class Descriptor:
"""Descriptor with proper per-instance storage"""
def __init__(self):
self.data = weakref.WeakKeyDictionary()
def __get__(self, obj, objtype=None):
if obj is None:
return self
return self.data.get(obj, "default value")
def __set__(self, obj, value):
self.data[obj] = value
class Book:
title = Descriptor()
book1 = Book()
book2 = Book()
book1.title = "Dune"
book2.title = "Foundation"
print(book1.title) # "Dune"
print(book2.title) # "Foundation"
# When book1 is deleted, its entry disappears automatically
# No memory leak - weakref doesn't keep objects alive
"Using WeakKeyDictionary prevents memory leaks," Margaret explained. "The descriptor stores data for each instance without keeping those instances alive. When an object is garbage collected, its descriptor data disappears automatically. This is the proper pattern for descriptors that store per-instance state."
Read-Only Descriptors
Timothy learned to create truly read-only attributes:
# Non-data descriptor - can be overridden
class ReadOnlyNonData:
def __init__(self, value):
self.value = value
def __get__(self, obj, objtype=None):
if obj is None:
return self
return self.value
class Example1:
attr = ReadOnlyNonData(42)
obj1 = Example1()
print(obj1.attr) # 42
# Can override because it's non-data descriptor (no __set__)
obj1.attr = 99 # Sets instance attribute
print(obj1.attr) # 99 - instance dict wins!
# Data descriptor - truly read-only
class ReadOnlyData:
def __init__(self, value):
self.value = value
def __get__(self, obj, objtype=None):
if obj is None:
return self
return self.value
def __set__(self, obj, value):
raise AttributeError("Read-only attribute")
class Example2:
attr = ReadOnlyData(42)
obj2 = Example2()
print(obj2.attr) # 42
try:
obj2.attr = 99 # Raises AttributeError
except AttributeError as e:
print(e) # "Read-only attribute"
"Non-data descriptors (only __get__) can be overridden by instance attributes," Margaret explained. "For truly read-only attributes, define __set__ to raise an exception. This makes it a data descriptor that overrides instance dict."
Descriptors and Inheritance
Margaret showed Timothy how descriptors worked with inheritance:
class Descriptor:
def __get__(self, obj, objtype=None):
if obj is None:
return self
print(f"Called on class: {objtype.__name__}")
return f"value from {objtype.__name__}"
class Parent:
attr = Descriptor()
class Child(Parent):
pass
# Descriptor is inherited
child = Child()
print(child.attr)
# Called on class: Child
# value from Child
# The objtype parameter is Child, not Parent!
# This is why descriptors receive objtype - to know the actual class
"Descriptors are inherited like any class attribute," Margaret explained. "But the objtype parameter is the actual class accessing the descriptor, not the class where it's defined. This lets descriptors adapt behavior based on the subclass."
The Method Descriptor
Timothy learned how regular methods worked:
class Function:
"""Simplified function descriptor"""
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
if obj is None:
return self # Access from class
# Return bound method - inject self
def bound_method(*args, **kwargs):
return self.func(obj, *args, **kwargs)
return bound_method
class Book:
def get_info(self):
return f"{self.title} - {self.pages} pages"
# Python wraps this in a function descriptor automatically
book = Book()
# When you access book.get_info, the descriptor's __get__ is called
# It returns a bound method with 'self' injected
"Regular instance methods are descriptors too," Margaret revealed. "Python wraps functions in a descriptor that binds self when accessed through an instance."
Descriptor Lookup Order
Margaret showed Timothy the complete attribute lookup process:
# When you write: obj.attr
# Python follows this order:
# 1. Check if attr is a data descriptor in obj's class
# (has __get__ and __set__)
# If found, call __get__
# 2. Check obj.__dict__ for 'attr'
# If found, return value
# 3. Check if attr is a non-data descriptor in obj's class
# (has only __get__)
# If found, call __get__
# 4. Check class.__dict__ and parent classes
# If found, return value
# 5. Raise AttributeError
class DataDesc:
def __get__(self, obj, objtype=None):
return "data descriptor"
def __set__(self, obj, value):
pass
class NonDataDesc:
def __get__(self, obj, objtype=None):
return "non-data descriptor"
class Example:
data = DataDesc()
non_data = NonDataDesc()
regular = "class attribute"
obj = Example()
obj.__dict__['data'] = "instance value" # Ignored
obj.__dict__['non_data'] = "instance value" # Takes precedence
obj.__dict__['regular'] = "instance value" # Takes precedence
print(obj.data) # "data descriptor" (step 1)
print(obj.non_data) # "instance value" (step 2)
print(obj.regular) # "instance value" (step 2)
"This lookup order explains why properties override instance attributes," Margaret explained. "Properties are data descriptors—they win over the instance dictionary."
Real-World Example: Validated Fields
Margaret demonstrated a production-ready pattern:
class ValidatedField:
"""Descriptor with customizable validation"""
def __init__(self, validator=None, default=None):
self.validator = validator
self.default = default
def __set_name__(self, owner, name):
self.name = name
self.private_name = f'_{name}'
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, self.default)
def __set__(self, obj, value):
if self.validator:
self.validator(self.name, value)
setattr(obj, self.private_name, value)
def positive_int(name, value):
"""Validator for positive integers"""
if not isinstance(value, int):
raise TypeError(f"{name} must be int")
if value < 0:
raise ValueError(f"{name} must be positive")
def non_empty_string(name, value):
"""Validator for non-empty strings"""
if not isinstance(value, str):
raise TypeError(f"{name} must be str")
if not value.strip():
raise ValueError(f"{name} cannot be empty")
class Book:
title = ValidatedField(non_empty_string)
author = ValidatedField(non_empty_string)
pages = ValidatedField(positive_int)
year = ValidatedField(positive_int)
isbn = ValidatedField(default="")
def __init__(self, title, author, pages, year, isbn=""):
self.title = title
self.author = author
self.pages = pages
self.year = year
self.isbn = isbn
# All validation automatic
book = Book("Dune", "Herbert", 412, 1965, "9780441013593")
try:
book.pages = -100
except ValueError as e:
print(e) # "pages must be positive"
try:
book.title = " "
except ValueError as e:
print(e) # "title cannot be empty"
Descriptors vs Properties: When to Use Which
Margaret clarified the choice:
Use properties when:
- Single attribute with custom getter/setter
- Logic specific to one class
- Simple validation or computation
- Readable, obvious intent
Use descriptors when:
- Reusable validation across multiple attributes
- Same logic needed in multiple classes
- Complex protocols (caching, type checking, etc.)
- Building frameworks or libraries
# Property - simple, one-off
class Book:
@property
def pages(self):
return self._pages
@pages.setter
def pages(self, value):
if value < 0:
raise ValueError("Pages must be positive")
self._pages = value
# Descriptor - reusable, multiple attributes
class PositiveInt:
# Reusable validation
pass
class Book:
pages = PositiveInt()
year = PositiveInt()
copies_sold = PositiveInt()
Timothy's Descriptor Wisdom
Through exploring the Descriptor Workshop, Timothy learned essential principles:
Descriptors control attribute access: Objects with __get__, __set__, or __delete__ methods.
Descriptors must be class attributes: They don't work in instance __dict__—only class-level attributes activate the protocol.
obj is None for class access: When accessing from class, not instance—return descriptor itself for introspection.
Properties are descriptors: @property creates a descriptor that calls your methods.
Classmethods are descriptors: __get__ injects the class as first argument.
Staticmethods are descriptors: __get__ returns the function unchanged.
Regular methods are descriptors: Python binds self through the descriptor protocol.
Data descriptors have get and set: They override instance __dict__.
Non-data descriptors have only get: Instance __dict__ can override them.
cached_property is a non-data descriptor: After first call, stores value in instance dict for fast subsequent access.
Use WeakKeyDictionary for per-instance data: Prevents memory leaks when descriptors store instance-specific values.
Read-only via set raising exception: Non-data descriptors can be overridden; data descriptors with raising __set__ cannot.
set_name provides automatic naming: Python 3.6+ tells descriptors their attribute name.
Descriptors are inherited: The objtype parameter reflects the actual accessing class, not the defining class.
Descriptors enable reusable validation: Define once, use on multiple attributes.
Descriptor lookup order matters: Data descriptors → instance dict → non-data descriptors → class dict.
Use descriptors for reusable patterns: Type checking, validation, caching, lazy evaluation.
Use properties for simple cases: One-off getters and setters.
Descriptors power Python's object model: Properties, methods, classmethods, staticmethods all use descriptors.
functools.cached_property is built-in: Standard library implementation of caching descriptor.
Descriptors are advanced Python: Most code doesn't need custom descriptors—properties suffice.
The Python Descriptor Protocol
Timothy had discovered the deepest mechanism in Python's object model—the descriptor protocol that powered properties, methods, classmethods, and staticmethods.
The Descriptor Workshop revealed that attribute access wasn't simple lookup but a sophisticated protocol that could intercept, validate, compute, and cache.
He learned the critical limitation—descriptors must be class attributes to work, not instance attributes—and understood why descriptors checked if obj was None to handle class-level access for introspection.
He built reusable validation descriptors that enforced contracts across multiple attributes, understood why data descriptors overrode instance dictionaries while non-data descriptors didn't, and discovered the proper WeakKeyDictionary pattern for storing per-instance data without memory leaks.
He learned that read-only attributes required data descriptors with __set__ raising exceptions, that descriptors worked with inheritance by receiving the actual accessing class in objtype, and that Python's own functools.cached_property leveraged non-data descriptors for efficient caching.
Yet he also learned restraint. Most code worked perfectly with simple properties, and custom descriptors were tools for frameworks and libraries, not everyday applications.
The Object-Oriented Manor's final workshop had revealed Python's most powerful pattern—and the wisdom to use it sparingly.
Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.
Top comments (0)