Python Descriptors Practice Problems & Exercises
Practice: Descriptors
← Back to lessonEasy
Create a ReadOnly descriptor that lets you read a value but raises AttributeError on any write attempt.
class ReadOnly:
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = "_ro_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name)
def __set__(self, obj, value):
# Allow setting only if the private attr doesn't exist yet (initial set)
if hasattr(obj, self.private_name):
raise AttributeError(f"{self.public_name} is read-only")
setattr(obj, self.private_name, value)
class Circle:
radius = ReadOnly()
def __init__(self, radius):
self.radius = radius # First set — allowed
c = Circle(5)
print(f"radius = {c.radius}")
try:
c.radius = 10
except AttributeError as e:
print(f"AttributeError: {e}")Expected Output
radius = 5
AttributeError: radius is read-onlyHints
Hint 1: Implement __get__ to return the stored value and __set__ to raise AttributeError.
Hint 2: Store the value in the instance dict using a mangled private key to avoid collisions.
Demonstrate the lookup priority difference between a data descriptor and a non-data descriptor by letting the instance dict win in one case and the descriptor win in the other.
class NonDataDescriptor:
"""Only __get__ — instance dict wins."""
def __get__(self, obj, objtype=None):
if obj is None:
return self
return "from non-data descriptor"
class DataDescriptor:
"""__get__ and __set__ — descriptor always wins."""
def __set_name__(self, owner, name):
self.private = "_dd_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return "VALIDATED: " + getattr(obj, self.private, "")
def __set__(self, obj, value):
setattr(obj, self.private, str(value))
class Demo:
non_data = NonDataDescriptor()
data = DataDescriptor()
d = Demo()
# Inject directly into instance __dict__ to shadow non-data descriptor
d.__dict__["non_data"] = "overridden"
print(f"Non-data: instance dict wins -> {d.non_data!r}")
# Try to shadow data descriptor via instance dict — won't work
d.__dict__["data"] = "overridden"
d.data = "hello"
print(f"Data: descriptor wins -> {d.data!r}")Expected Output
Non-data: instance dict wins -> 'overridden'
Data: descriptor wins -> 'VALIDATED: hello'Hints
Hint 1: A non-data descriptor implements only __get__. An instance dict entry shadows it.
Hint 2: A data descriptor implements __set__ (or __delete__). It takes priority over the instance dict.
Write a Typed descriptor that enforces the type of the value assigned to it. Use it in a Temperature class.
class Typed:
def __init__(self, expected_type):
self.expected_type = expected_type
self.name = None
def __set_name__(self, owner, name):
self.name = name
self.private = "_typed_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private, None)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"Expected {self.expected_type} for '{self.name}', got {type(value)}"
)
setattr(obj, self.private, value)
class Temperature:
value = Typed(float)
def __init__(self, value):
self.value = value
def __repr__(self):
return f"Temperature: {self.value}"
t = Temperature(98.6)
print(t)
try:
t.value = "hot"
except TypeError as e:
print(f"TypeError: {e}")Expected Output
Temperature: 98.6
TypeError: Expected float for 'value', got <class 'str'>Hints
Hint 1: In __set__, call isinstance(value, self.expected_type) and raise TypeError if it fails.
Hint 2: Use __set_name__ to capture the attribute name for use in error messages.
Medium
Build a cached_property-style descriptor that computes a value once and caches it on the instance. The expensive computation should fire only on the first access.
import math
class cached_property:
def __init__(self, func):
self.func = func
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Not in instance dict yet — compute and store
value = self.func(obj)
obj.__dict__[self.name] = value
return value
class Circle:
def __init__(self, radius):
self.radius = radius
self._compute_count = 0
@cached_property
def area(self):
print("Computing area...")
self._compute_count += 1
return math.pi * self.radius ** 2
c = Circle(5)
a1 = c.area # triggers computation
a2 = c.area # should hit instance dict, no recompute
print(f"Area = {a2}")
print(f"Area = {c.__dict__['area']}")
print(f"Computed only once: {c._compute_count == 1}")Expected Output
Computing area...
Area = 78.53981633974483
Area = 78.53981633974483
Computed only once: TrueHints
Hint 1: A non-data descriptor (only __get__) lets the instance dict shadow it after the first call.
Hint 2: Store the computed result in obj.__dict__[self.name] on first access. Subsequent accesses bypass the descriptor entirely because the instance dict takes priority.
Create a RangedValue descriptor that enforces numeric bounds. Apply it to a Vehicle class for speed and rpm.
class RangedValue:
def __init__(self, min_val, max_val):
self.min_val = min_val
self.max_val = max_val
self.name = None
def __set_name__(self, owner, name):
self.name = name
self.private = "_rv_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private, self.min_val)
def __set__(self, obj, value):
if not (self.min_val <= value <= self.max_val):
raise ValueError(
f"{self.name} must be between {self.min_val} and {self.max_val}, got {value}"
)
setattr(obj, self.private, value)
class Vehicle:
speed = RangedValue(0, 200)
rpm = RangedValue(0, 8000)
def __init__(self, speed=0, rpm=0):
self.speed = speed
self.rpm = rpm
v = Vehicle()
v.speed = 60
print(f"Speed set to {v.speed}")
try:
v.speed = 250
except ValueError as e:
print(f"ValueError: {e}")
try:
v.speed = -10
except ValueError as e:
print(f"ValueError: {e}")Expected Output
Speed set to 60
ValueError: speed must be between 0 and 200, got 250
ValueError: speed must be between 0 and 200, got -10Hints
Hint 1: Capture min_val and max_val in __init__. In __set__, compare the value against both bounds.
Hint 2: Use __set_name__ to capture the attribute name so the error message can name the field.
Build a UnitField descriptor that stores distances internally in metres but exposes them in any desired unit (miles, km). Demonstrate bidirectional conversion.
class UnitField:
"""Stores value internally in metres; converts on get/set."""
def __init__(self, to_metres: float, unit_name: str):
self.to_metres = to_metres # multiply input by this to get metres
self.unit_name = unit_name
self.private = None
def __set_name__(self, owner, name):
self.private = "_uf_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
metres = getattr(obj, "_uf_metres", 0.0)
return metres / self.to_metres
def __set__(self, obj, value):
# Always store in metres
object.__setattr__(obj, "_uf_metres", value * self.to_metres)
class Distance:
miles = UnitField(1609.344, "miles")
km = UnitField(1000.0, "km")
def __init__(self, miles=0.0):
self.miles = miles
d = Distance(miles=1.0)
print(f"Stored (metres): {d._uf_metres}")
print(f"Miles: {d.miles}")
print(f"Kilometres: {d.km}")Expected Output
Stored (metres): 1609.344
Miles: 1.0
Kilometres: 1.609344Hints
Hint 1: Store the value internally in a canonical unit (metres). Implement __get__ to convert back to the display unit.
Hint 2: Create two descriptor instances: one for miles and one for km, each with its own conversion factor.
Write an Audited descriptor that keeps a full history of every value assigned to it. Use it to track salary changes.
class Audited:
def __set_name__(self, owner, name):
self.name = name
self.private_val = "_aud_val_" + name
self.private_hist = "_aud_hist_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_val, None)
def __set__(self, obj, value):
# Record history
history = getattr(obj, self.private_hist, [])
history.append(value)
object.__setattr__(obj, self.private_hist, history)
object.__setattr__(obj, self.private_val, value)
def history(self, obj):
return list(getattr(obj, self.private_hist, []))
class Employee:
salary = Audited()
def __init__(self, name, salary):
self.name = name
self.salary = salary
def salary_history(self):
return Employee.salary.history(self)
e = Employee("Alice", 80000)
e.salary = 95000
e.salary = 120000
print(f"Current salary: {e.salary}")
print(f"History: {e.salary_history()}")
print(f"Raises: {len(e.salary_history()) - 1}")Expected Output
Current salary: 120000
History: [80000, 95000, 120000]
Raises: 2Hints
Hint 1: Store a list in the instance dict under a private key. Append each new value to it on every __set__.
Hint 2: Expose the history list via a second descriptor or a regular method — choose whichever feels cleaner.
Hard
Implement myproperty — a complete re-implementation of the built-in property descriptor supporting getter, setter, and deleter chaining with the @prop.setter decorator pattern.
import math
class myproperty:
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.__doc__ = doc or (fget.__doc__ if fget else None)
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)
class Circle:
def __init__(self, radius):
self._radius = radius
@myproperty
def radius(self):
"""The circle's radius."""
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError(f"radius must be positive, got {value}")
self._radius = value
@radius.deleter
def radius(self):
print("Deleted radius — reset to 1")
self._radius = 1
@myproperty
def area(self):
return math.pi * self._radius ** 2
c = Circle(5)
print(f"Area: {c.area}")
try:
c.radius = -1
except ValueError as e:
print(f"ValueError: {e}")
del c.radiusExpected Output
Area: 78.53981633974483
ValueError: radius must be positive, got -1
Deleted radius — reset to 1Hints
Hint 1: property stores three callables: fget, fset, and fdel. __get__ calls fget(obj), __set__ calls fset(obj, value), __delete__ calls fdel(obj).
Hint 2: Implement a .setter and .deleter method that return a new myproperty instance with the updated callback, mirroring the standard property API.
Build an Observable descriptor that fires registered callbacks whenever a value changes. Support multiple callbacks and a threshold-alert system.
class Observable:
def __set_name__(self, owner, name):
self.name = name
self.private = "_obs_" + name
self.listeners_key = "_obs_listeners_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private, None)
def __set__(self, obj, value):
old = getattr(obj, self.private, None)
object.__setattr__(obj, self.private, value)
listeners = getattr(obj, self.listeners_key, [])
for cb in listeners:
cb(self.name, old, value)
def add_listener(self, obj, callback):
listeners = getattr(obj, self.listeners_key, [])
listeners = list(listeners) + [callback]
object.__setattr__(obj, self.listeners_key, listeners)
class Sensor:
temperature = Observable()
def __init__(self, temp, alert_threshold=None):
self.temperature = temp
# Register change logger
Sensor.temperature.add_listener(self, self._log_change)
if alert_threshold is not None:
self._threshold = alert_threshold
Sensor.temperature.add_listener(self, self._check_threshold)
def _log_change(self, attr, old, new):
if old is not None:
print(f"[CHANGE] {attr}: {old} -> {new}")
def _check_threshold(self, attr, old, new):
if new > self._threshold:
print(f"[ALERT] {attr} exceeded {self._threshold}! Current: {new}")
s = Sensor(20, alert_threshold=50)
s.temperature = 37
s.temperature = 100
print(f"Final temperature: {s.temperature}")Expected Output
[CHANGE] temperature: 20 -> 37
[CHANGE] temperature: 37 -> 100
[ALERT] temperature exceeded 50! Current: 100
Final temperature: 100Hints
Hint 1: Store a list of callbacks on the descriptor itself (class-level). Call them all inside __set__ after updating the value.
Hint 2: Support passing both a simple on_change callback and a threshold alert callback. The descriptor should call all registered listeners.
Build a ForeignKey descriptor that stores an integer ID but resolves it to the actual related object on access — simulating an ORM foreign key.
class ForeignKey:
"""Stores an integer ID; resolves to the related object via a registry."""
def __init__(self, related_model):
self.related_model = related_model
self.name = None
def __set_name__(self, owner, name):
self.name = name
self.private = "_fk_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
fk_id = getattr(obj, self.private, None)
if fk_id is None:
return None
registry = getattr(self.related_model, "_registry", {})
if fk_id not in registry:
raise KeyError(f"{self.related_model.__name__} with id={fk_id} not found")
return registry[fk_id]
def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError(f"{self.name} must be an integer ID, got {type(value)}")
object.__setattr__(obj, self.private, value)
class User:
_registry = {}
def __init__(self, id, name, email):
self.id = id
self.name = name
self.email = email
User._registry[id] = self
class Order:
author = ForeignKey(User)
def __init__(self, order_id, author_id):
self.order_id = order_id
self.author = author_id
# Populate user registry
u1 = User(1, "Alice", "[email protected]")
u2 = User(2, "Bob", "[email protected]")
o = Order(101, author_id=1)
print(f"Order author: {o.author.name}")
print(f"Order author email: {o.author.email}")
try:
o.author = 99
print(o.author.name)
except KeyError as e:
print(f"KeyError: {e}")Expected Output
Order author: Alice
Order author email: [email protected]
KeyError: User with id=99 not foundHints
Hint 1: The descriptor stores only an integer ID in the instance dict. __get__ performs a lookup in a class-level registry (a dict).
Hint 2: The registry is keyed by integer IDs. __set__ validates that the given value is an int; __get__ resolves it to the actual object.
Write a FullProtocol descriptor that correctly handles all three access patterns: class-level access (returns self), instance get (returns value), instance delete (resets), and tracks total get invocations.
class FullProtocol:
def __init__(self, default=None):
self.default = default
self.get_count = 0
def __set_name__(self, owner, name):
self.name = name
self.private = "_fp_" + name
def __get__(self, obj, objtype=None):
if obj is None:
# Class-level access — return the descriptor itself
return self
self.get_count += 1
return getattr(obj, self.private, self.default)
def __set__(self, obj, value):
object.__setattr__(obj, self.private, value)
def __delete__(self, obj):
try:
object.__delattr__(obj, self.private)
except AttributeError:
pass # Already absent
class DataStore:
value = FullProtocol(default=None)
def __init__(self, value=None):
if value is not None:
self.value = value
# Class-level access
descriptor = DataStore.value
print(f"Class access returns descriptor: {descriptor is DataStore.value}")
# Instance access
ds = DataStore(42)
print(f"Instance value: {ds.value}") # get_count = 1
del ds.value
print(f"After del: {ds.value}") # get_count = 2
print(f"Descriptor invoked {DataStore.value.get_count} times")Expected Output
Class access returns descriptor: True
Instance value: 42
After del: None
Descriptor invoked 2 timesHints
Hint 1: When obj is None in __get__, the descriptor is being accessed via the class. Return self so the descriptor object is inspectable.
Hint 2: Implement __delete__ to reset the private attribute on the instance. Track invocation counts on the descriptor object itself (not on the instance).
