Skip to main content

Python Descriptors Practice Problems & Exercises

Practice: Descriptors

11 problems3 Easy4 Medium4 Hard70–90 min
← Back to lesson

Easy

#1Build a Read-Only DescriptorEasy
descriptor__get____set__read-only

Create a ReadOnly descriptor that lets you read a value but raises AttributeError on any write attempt.

Python
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-only
Hints

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.


#2Identify Data vs Non-Data DescriptorsEasy
descriptordata-descriptornon-data-descriptorlookup

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.

Python
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.


#3Typed Attribute DescriptorEasy
descriptortype-validation__set__

Write a Typed descriptor that enforces the type of the value assigned to it. Use it in a Temperature class.

Python
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

#4Caching Non-Data DescriptorMedium
descriptorcachinglazy-evaluation

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.

Python
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: True
Hints

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.


#5Validated Range DescriptorMedium
descriptorvalidationrange-check

Create a RangedValue descriptor that enforces numeric bounds. Apply it to a Vehicle class for speed and rpm.

Python
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 -10
Hints

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.


#6Unit-Converting DescriptorMedium
descriptorunit-conversion__get____set__

Build a UnitField descriptor that stores distances internally in metres but exposes them in any desired unit (miles, km). Demonstrate bidirectional conversion.

Python
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.609344
Hints

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.


#7Audit Log DescriptorMedium
descriptoraudithistory__set__

Write an Audited descriptor that keeps a full history of every value assigned to it. Use it to track salary changes.

Python
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: 2
Hints

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

#8Re-implement property from ScratchHard
descriptorpropertyprotocoldecorator

Implement myproperty — a complete re-implementation of the built-in property descriptor supporting getter, setter, and deleter chaining with the @prop.setter decorator pattern.

Python
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.radius
Expected Output
Area: 78.53981633974483
ValueError: radius must be positive, got -1
Deleted radius — reset to 1
Hints

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.


#9Observable Descriptor with Event SystemHard
descriptorobservereventcallback

Build an Observable descriptor that fires registered callbacks whenever a value changes. Support multiple callbacks and a threshold-alert system.

Python
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: 100
Hints

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.


#10Descriptor-Based Foreign Key SimulationHard
descriptorORMforeign-keylookup

Build a ForeignKey descriptor that stores an integer ID but resolves it to the actual related object on access — simulating an ORM foreign key.

Python
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 found
Hints

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.


#11Descriptor Protocol Stress Test — Class vs Instance AccessHard
descriptorprotocol__get__class-accessinstance-access

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.

Python
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 times
Hints

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).

© 2026 EngineersOfAI. All rights reserved.