DEV Community

Matthew Conway
Matthew Conway

Posted on

Python: Creating Instance Properties

The Python @property descriptor is used by developers as a neat way to create special class attributes. If you don’t know about Pythons descriptor protocol you should read up on it, there's plenty of great resources out there to describe how it works (especially the official Python docs). The goal of this article is to cover a slightly more "advanced" usage of @property descriptors which allows developers to create instance attributes that are derrived from a Python property class.

Remember that Python descriptors work on classes not instances! Therefore, all instances - of a specific Python class which contain "Python descriptor attributes" - will contain the same class attributes (aka the properties) that all share the same value. In other words, when you define an attribute for a class which is derrived from an @property descriptor, you are defining a class variable.

The following code snippet defines a (simplest) Python descriptor class:

class MyDescriptor:

    def __init__(self, value):
        self._value = value

    def __get__(self, owner_instance, owner_type):
        return self._value

    def __set__(self, owner_instance, value):
        self._value = value

You may think usage for this simple descriptor might look something like:

class MyClass:

    bar = MyDescriptor("bar")

    def __init__(self):
        self.foo = MyDescriptor("foo")

Lets start with the class variable MyClass.bar:

first_instance = MyClass()
second_instance = MyClass()

print(f"first_instance.bar = {first_instance.bar}")
# prints "bar"

first_instance.bar = "bar new"

print(f"second_instance.bar = {first_instance.bar}")
# prints "bar new"

This is exactly what we should expect - considering MyClass.bar was defined on the class (so all instances share the same value for that attribute).

However, looking into the instance attribute MyClass.foo may not yeild the expected result, for example:

first_instance = MyClass()
second_instance = MyClass()

print(f"first_instance.foo = {first_instance.foo}")
# prints "<__main__.MyDescriptor object at 0x7f87492899e8>"

print(f"second_instance.foo = {second_instance.foo}")
# prints "<__main__.MyDescriptor object at 0x7f87492897a9>"

Note: You may have expected to see the value held by the descriptor MyClass.foo be the value printed. However - as I mentioned before - Python descriptors work on classes not instances! So what does the descriptor protocol do to get/set this value? Well, that's simple... it calls the __get__ and __set__ methods defined on your descriptor class! Armed with this knowledge you can simply "manually" implement this functionality yourself. How? By creating a custom base class:

class Base:
    """
    A base class
    """

    def __setattr__(self, attr, value):
        try:
            # Try invoking the descriptor protocol __set__ "manually"
            got_attr = super().__getattribute__(attr)
            got_attr.__set__(self, value)
        except AttributeError:
            # Attribute is not a descriptor, just set it:
            super().__setattr__(attr, value)

    def __getattribute__(self, attr):
        # If the attribute does not exist, super().__getattribute__()
        # will raise an AttributeError
        got_attr = super().__getattribute__(attr)
        try:
            # Try "manually" invoking the descriptor protocol __get__()
            return got_attr.__get__(self, type(self))
        except AttributeError:
            # Attribute is not a descriptor, just return it:
            return got_attr

This base class will override the standard __setattr__ and __getattribute__ methods of the class and tries to manually invoke the descriptor protocol to get/set the attribute value, if that fails it treats the attribute as a "normal" attribute by letting the super class (in this case, object) deal with the request (as normal).

Finally, the MyClass needs to be modified to inherit from Base:

class MyClass(Base):

    bar = MyDescriptor("bar")

    def __init__(self):
        self.foo = MyDescriptor("foo")

And using the class to create some instances and get values held by instance owned descriptor attributes:

first_instance = MyClass()
second_instance = MyClass()

print(f"first_instance.foo = {first_instance.foo}")
# prints "foo"

print(f"second_instance.foo = {second_instance.foo}")
# prints "foo"

first_instance.foo = "foo new"

print(f"first_instance.foo = {first_instance.foo}")
# prints "foo new"

print(f"second_instance.foo = {second_instance.foo}")
# prints "foo"

And there you have it! A clean & simple solution if you want Python descriptor attributes to work on a Python instance, not just the class.

Top comments (2)

Collapse
 
bootchk profile image
Lloyd Konneker

Nice. The Python docs about descriptors obscure the fact that descriptors are class variables, not instance variables.

There seem to be other ways to accomplish instance properties.

I have found dealing with descriptors very fragile, much to understand. Even your code. I cut and pasted your base class into my existing code that had class properties. And I do not get the same result: the first two lines of your final test still print something like <main.MyDescriptor object at 0x7f87492899e8>, but the last two lines print the same result as you show, as if you must set the instance property before getting it! Something strange I have done no doubt.

Collapse
 
mattconway1984 profile image
Matthew Conway

I've not looked into the full detail, but I'll give you an alternative approach using a metaclass:

class MyDescriptor:

    def __init__(self, value):
        self._value = value
        self._name = None

    def setup(self, instance, name):
        # This method should only be called from the metaclass
        self._name = name
        instance.__dict__[name] = self._value

    def __get__(self, instance, cls):
        if instance is None:
            return self
        try:
            val = instance.__dict__[self._name]
        except KeyError:
            print("No such attribute {self._name}")
            val = None
        return val

    def __set__(self, instance, value):
        instance.__dict__[self._name] = value

    def __delete__(self, instance):
        del(instance.__dict__[self._name])

The metaclass (this must be the base type) for your class:

class BarMeta:
    def __new__(cls, *args, **kwargs):
        # Create a new object for the class:
        obj = super().__new__(cls, *args, **kwargs)
        # Now, inspect the class attributes, to find any "MyDescriptors", when
        # one is found, call the setup method of the descriptor, which will set
        # the default value for the descriptor.
        for name, value in ((key, value) for key, value in cls.__dict__.items() if isinstance(value, MyDescriptor)):
            value.setup(obj, name)
        # Finally, return the newly created instance:
        return obj

And the class you want to use instance descriptors on, looks pretty normal:

class Bar(BarMeta):
    bar = MyDescriptor(0)
    baz = MyDescriptor("a baz")

A quick test:

>>> b = Bar()
>>> b.bar
0
>>> b.baz
'a baz'
>>> c = Bar()
>>> c.bar = 99
>>> c.bar
99 # holds new value of 99
>>> b.bar
0 # hasn't changed