pythonattributesinteractivepython-descriptors

Dynamic read-only attributes in python instances


EDIT This code contains several bugs, see jsbueno's answer below for a correct version

I would like to create read-only attributes that dynamically retrieve values from an internal dictionary. I have tried to implement these as descriptors:

from typing import Any

class AttDesc:
    def __init__(self, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        return obj._d[self.name]

    def __set__(self, obj, value):
        raise AtrributeError("Read only!")

class A:
    def __init__(self, klist: list[str], vlist: list[Any]) -> None:
        self._d = dict(zip(klist, vlist))
        for k in self._d:
            setattr(type(self), k, AttDesc(k))

    @property
    def d(self):
        return self._d

The problem with this approach is that the descriptor instances are class attributes. This means that, in an interactive session:

    a1 = A(['x', 'y'], [1, 2])
    a2 = A(['z', ], [3])

if I press TAB for autocomplete on a1. I will be given the option to choose the attribute z, which "belongs" to instance a2. I have also tried to implement via the instance's __getattr__ method:

class B:
    def __init__(self, klist: list[str], vlist: list[Any]):
        object.__setattr__(self, '_d', dict(zip(klist, vlist)))

    @property
    def d(self):
        return self._d

    def __getattr__(self, name):
        if name in self.d:
            return self.d[name]
        else:
            object.__getattr__(name)

    def __setattr__(self, k, v):
        if k in self.d:
            raise AttributeError("Read only!")
        object.__setattr__(self, k, v)

If I try b = B(['w'], [3]) in an interactive session, pressing TAB on b. won't show w as an option, because it's not an instance attribute.

Pandas does something similar to what I want: it allows accessing the columns of a DataFrame with the dot operator, and only the appropriate columns for a given instance show up upon pressing TAB in an interactive session. I have tried to look into the Pandas code but it is a bit abstruse to me. I think they use something similar to my second __getattr__ option, but I don't understand how they make it work.

How could I implement this behaviour?


Solution

  • Descriptors are always implemented on the class - So, yes, if you instantiate one object, and change class attributes when doing so, you will change all other instances automatically - that is how class and instances object work in Python and a large number of other OOP languages.

    Descriptors are a mechanism which operates in the class namespace - but Python dynamism allows you to create other customizations.

    In this case, all you need is a custom __setattr__ attribute, along with a __getattr__ and __dir__ methods (__dir__ should make autocomplete work for most tools).

    
    from types import MappingProxyType
    from typing import Any
    
    class A:
        _d = {}
        def __init__(self, klist: list[str], vlist: list[Any]) -> None:
            self._d = dict(zip(klist, vlist))
    
        @property
        def d(self):
            # Read only dict:
            return MappingProxyType(self._d)
        
        
        def __setattr__(self, attrname, value):
            if attrname in self._d:
                raise TypeError("Read only attribute")
            return super().__setattr__(attrname, value)
            
        def __dir__(self):
            attrs = super().__dir__()
            attrs.remove("_d")
            attrs.extend(self._d.keys())
            return attrs
        
        def __getattr__(self, attrname):
            try:
                return self._d[attrname]
            except KeyError as exc:
                raise AttributeError(attrname)
            
            
    a = A(['x', 'y'], [1, 2])
    b = A(['z', ], [3])
    

    And in the interactive mode:

    
    
    In [22]: a.x
    Out[22]: 1
    
    In [23]: b = A(['z', ], [3])
    
    In [24]: b.z
    Out[24]: 3
    
    In [25]: a.z
    ---------------------------------------------------------------------------
    ...
    ...
    AttributeError: z
    
    In [26]: b.z = 5
    ---------------
    ...
    TypeError: Read only attribute
    
    
    # and pressing tab after `a.`:
    In [29]: a.d
                d x y