1

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?

8
  • Why are a and b instances of the same class if they have different interfaces? This seems like a design problem that's independent of any IDE-specific features. Commented Mar 17 at 13:11
  • I edited the post to avoid this confusion. I now call the instances of A a1 and a2, and b is the instance of B. Commented Mar 17 at 13:46
  • That wasn't my confusion: I have the same question about why a1 and a2 are instances of the same class if they have different interfaces. Commented Mar 17 at 13:56
  • I am still not sure if I understand you correctly. I want to create a class that has attribute-like access to an internal dictionary, much like Pandas provides attribute-like access for the columns of a DataFrame. Different DataFrame instances can have differently named columns. But they are instances of the same class. Maybe I could dynamically create a factory of classes derived from a common base and have attributes set according to the internal dictionary, but why would I do that if I have a much simpler option available? Commented Mar 17 at 14:22
  • 1
    And I'm saying that's not a good design, at least not if you are concerned about static behavior such as autocomplete assumes. Commented Mar 17 at 14:27

1 Answer 1

0

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

Sign up to request clarification or add additional context in comments.

2 Comments

Thank you for the informative answer; I was unaware that the __dir__ method existed. Is there any reason to prefer super().__setattr__ to object.__setattr__? I see the latter one a lot in the Pandas code. Maybe because the parent class' __setattr__ is also overloaded?
Yes, super().__setattr__ is the "correct way to do it" , because if you do compose this class with others which also do customize setattr, they could work in a collaborative way. Callign directly object.__setattr__ is a way of closing up the design, so no other collaboration on this side is possible - you can simply do that, but if everyone would prefer to simply close all other customization and expansion doors, it is likely Python wouldn't allow anywhere this level of customization for objects to start with. It is part of the language "zeitgeist" to leave doors open.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.