Python Property() and Descriptors

eastrittmatter

Eve S

Posted on March 8, 2024

Python Property() and Descriptors

In Python I have been making frequent use of the property decorator:

class Foo:
    def __init__(self, a):
        self.a = a
    @property 
    def a(self):
        return self._a
    @name.setter 
    def a(self, a):
        self._a = a
Enter fullscreen mode Exit fullscreen mode


A decorator (denoted by '@') is a fast and clean way to wrap a function in another function. The above code written without using a decorators is

class Foo:
    def __init__(self, a):
        self.a = a
    def getter(self):
        return self._a
    a = property(getter)
    def setter(self, a):
        self._a = a
    a = a.setter(setter)
Enter fullscreen mode Exit fullscreen mode

This reveals a bit more about what is happening. We are assigning our new variable to the property function with our getter function as an argument, which returns a special type of object called a descriptor. This descriptor has a method setter() which we use to assign the setter function. We could just as easily define the getter and setter then say a = property(getter, setter) to do both in one line, and property can even take a third argument, a deleter. We can check a.fget is getter # True to see that our a's getter is the same in memory as the getter function we defined.

The __init__ method wasn't needed up to this point, but a
property isn't very useful without it. When we instantiate a new Foo object and set its a attribute, Python will use the getter method stored in the Foo class to assign the new object's 'a'. The same goes for reassigning 'a', since the setter will be called. Most commonly this has been useful to me for checking if a new value is acceptable before changing it, manipulating it (by changing type, for example) to fit if needed, and giving an error otherwise.

But how exactly does property work? The object property returns has these attributes fget and fset that point to getter and setter, but that alone isn't enough for it to work. Like any descriptor, it uses magic methods to hook into its basic functionality as an object. In the Python doc on descriptors, there is an example of a class that uses __get__ and __set__ to reproduce some of the functionality of property. I've cut it down some:

class LoggedAgeAccess:

    def __get__(self, obj, objtype=None):
        return obj._age

    def __set__(self, obj, value):
        obj._age = value

class Person:

    age = LoggedAgeAccess() # Descriptor instance

    def __init__(self, name, age):
        self.name = name # Regular instance attribute
        self.age = age # Calls __set__()

    def birthday(self):
        self.age += 1 # Calls both __get__() and __set__()
Enter fullscreen mode Exit fullscreen mode

__get__ is the run when the descriptor instance is evaluated. Creating Person instantiates a LoggedAgeAccess object. Writing p = Person('name', 4); p.age, runs the LoggedAgeAccess instance's __get__ method, which passes the descriptor instance (self), then p, then Person. As __get__ is defined, it will return p.age. The "" marks a private instance which accesses the attribute value without using the descriptor, which if done would cause a loop.

__set__ works similarly and is run when the attribute is reassigned. Note the line self.age = age does not need to call __get__ because it does not need to evaluate the left hand side as an expression.

Defining magic methods in a class is an example of "operator overloading." Every class, even if you don't define it as a subclass, inherits from the "object" class and comes with these magic methods ready-made. They can handle what happens when you instantiate their objects, add them to each other with the plus sign, compare them with ==, and more. When you define one of these magic methods inside a class you are actually overwriting the previous definition of the method, and your new one will be referred to first as the case is with variable scope.

The code example above only works for a making a managed variable named age, but with one more magic function it can be made much more useful.

class LoggedAccess:

    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None):
        return getattr(obj, self.private_name)

    def __set__(self, obj, value):
        setattr(obj, self.private_name, value)
Enter fullscreen mode Exit fullscreen mode

The above class makes use of __set_name__, which checks the name of the variable the class instance is assigned to. getattr and setattr exist for every object and do what they say in the name - they too can be overwritten for a class with __getattr__ and __setattr__.

It is easy enough to added some data validation before setattr to determine if the reassignment should occur, and Python even has an inbuilt Validator class that makes this easier (custom calidatorscan be made as subclasses by being defined with TheClassName(Validator)).

The property decorator, along with the classmethod decorator and others, can handle all this work of hooking in with descriptors for us, but often we need to start using magic methods ourselves to change how our custom objects behave. __init__ is all over the place! This type of work can get close to the underlying C Python is written in. I'll include here the source code for property, a pure Python sample implementation of the property class from the official descriptor guide and a Stack Overflow post that explained it well. If you're surprised that Property is a class in this implementation, don't be! Functions are first class objects in Python. When you write def foo(), you are making 'foo' an object of the type "function". This Property class is similar to a function in that it takes arguments to create an instance which will will rely on its magic methods to be referenced, and can even be referenced just by being evaluated on it's own because of __get__.

class Property:
    "Emulate PyProperty_Type() in Objects/descrobject.c"

    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        if doc is None and fget is not None:
            doc = fget.__doc__
        self.__doc__ = doc

    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__)
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
eastrittmatter
Eve S

Posted on March 8, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related