pythonpropertiespython-descriptors

Python descriptors on Readonly attributes


I want to refactor a big part of my code into a generic descriptor for read only attribute access. The following is an example of property based implementation

class A:
    def __init__(self, n):
        self._n = n
        self._top = -1

    @property
    def n(self):
        return self._n

    @property
    def top(self):
        return self._top

    def increase(self):
        self._top += 1

you see I can initialize my A class and increase self._top but not to let user set a.top by omitting property setter method

a = A(7)
a.top
Out[25]: -1
a.increase()
a.top
Out[27]: 0

if I do a.top = 4, it will give me an error

AttributeError: property 'top' of 'A' object has no setter

which is expected. Now, I want to refactor this logic into a descriptor

class ReadOnly:

    def __init__(self):
        self._name = None

    def __set_name__(self, owner, name):
        self._name = name

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.__dict__[self._name]

    def __set__(self, instance, value):
        raise AttributeError("Can't set attribute")

    def __delete__(self, instance):
        raise AttributeError("Can't delete attribute")

class A:
    n = ReadOnly()
    top = ReadOnly()

    def __init__(self, n):
        self.n = n
        self.top = -1

    def increase(self):
        self.top += 1

Well, this doesn't work. I can't even initialize the class A anymore, cause in __init__ it will set n and top immediately and prevent my from initialize.

How to write this logic from property into descriptor?

P.S.

Thank @chepner for this solution. This is what I'm looking for. I made it work. One last thing, if I have a attribute is a list say

class Stack:
    S = ReadOnly()
    n = ReadOnly()
    top = ReadOnly()

    def __init__(self, n):
        self._S = [None] * n
        self._n = n
        self._top = -1  # python offset 1

Now I can't change self.top anymore

>>> s = Stack(4)
>>> s.S
[None, None, None, None]

Nor I can change s

>>> s.S = [1, 3]  # not allowed anymore. Great!
But I can still change an element in the list
>>> s.S[3] = 3
[None, None, None, 3]

How can I prevent list element changes?


Solution

  • Instead of disallowing all modifications, simply check if the attribute exists on instance before creating it. If it already exists, raise the AttributeError. Otherwise, let the attribute be created.

    def __set__(self, instance, value):
        if self._name in instance.__dict__:
            raise AttributeError("Can't set attribute")
        instance.__dict__[self._name] = value
    

    Also, if I remember correctly, --

    Update: I did not. If the class attribute has __get__ and __set__, it takes priority over an instance attribute of the same name. Here is a good reference. However, I would still give the instance attribute a different name for clarity.

    --you need to use a different name for the underlying private attribute so that you don't shadow the descriptor.

    def __set_name__(self, owner, name):
        self._name = "_" + name