I already talked in the past about Python descriptors [1] and [2] (referencing also the complex attribute lookup process). Somehow I've recently realised of how some commonly used attributes are managed with descriptors present in classes or metaclasses. First, I'll paste here the conclusions after an interesting chat with a GPT regarding the attibute lookup process:
1) Instance attribute lookup (obj.attr)
This is (conceptually) what object.__getattribute__(obj, name) does: a) Check for a data descriptor on the class or its MRO Search type(obj).__mro__ for name in each class’s __dict__. If found and it’s a data descriptor (has __set__ or __delete__), return descriptor.__get__(obj, type(obj)). b) Check the instance’s own dictionary If obj.__dict__ exists and contains name, return obj.__dict__[name]. Note: If the class defines __slots__ without __dict__, this step may not exist. c) Check for a non-data descriptor or other attribute on the class/MRO Search type(obj).__mro__ for name. If found and it’s a non-data descriptor (has __get__ only), return descriptor.__get__(obj, type(obj)). Otherwise, return the found value as-is. d) Fallback: __getattr__ If nothing above produced a value, and type(obj) defines __getattr__(self, name), call it and return its result. e) Otherwise Raise AttributeError.
2) class attribute lookup (C.attr)
Conceptually, type.__getattribute__(C, name) does this: a) Metaclass MRO — data descriptors first Search type(C).__mro__. If name is found and it’s a data descriptor (__set__ or __delete__ present), return descriptor.__get__(None, C). b) Class MRO (C and its bases) — regular attributes & descriptors Search C.__mro__ (starting with C, then bases): If found and it’s a descriptor (__get__), return descriptor.__get__(None, C) (note obj=None). Otherwise, return the raw value. c) Metaclass MRO — non-data descriptors and other attributes If found on the metaclass MRO and it’s a descriptor, return descriptor.__get__(C, type(C)) (here, the “instance” is the class C) Otherwise return the value. e) Fallback If not found and the metaclass defines __getattr__(cls, name), call it. Else raise AttributeError.
Let's see now some examples of attributes that are indeed descriptors:
__name__ of a class (Person.__name__). One could think that it's just an attribute directly in the class object, but if it were that way, I could acces it via an instance of the class (person1.__name__) that is not the case. So indeed __name__ is a descriptor in the metaclass (and exactly the same for __bases__ or __doc__):
>>> class Person:
... pass
...
>>> Person().__name__
Traceback (most recent call last):
Person().__name__
AttributeError: 'Person' object has no attribute '__name__'
>>> Person.__name__
'Person'
>>> Person.__dict__["__name__"]
Traceback (most recent call last):
Person.__dict__["__name__"]
~~~~~~~~~~~~~~~^^^^^^^^^^^^
KeyError: '__name__'
>>> type(Person).__dict__["__name__"]
attribute '__name__' of 'type' objects
>>> type(type(Person).__dict__["__name__"])
class 'getset_descriptor'
>>> type(Person).__dict__["__bases__"]
attribute '__bases__' of 'type' objects
>>> type(type(Person).__dict__["__bases__"])
class 'getset_descriptor'>
>>> type(type(Person).__dict__["__doc__"])
class 'getset_descriptor'
__class__ of an instance or __class__ of a class. This one does not seem be based on descriptors, but (my discussion with a GPT is a bit confusing) it seem like it's managed specially by the look up algorithm.
>>> p1 = Person()
>>> p1.__class__
class '__main__.Person'
>>> type.__class__
class 'type'
>>> type(p1.__dict__["__class__"])
Traceback (most recent call last):
type(p1.__dict__["__class__"])
~~~~~~~~~~~^^^^^^^^^^^^^
KeyError: '__class__'
>>> type(Person.__dict__["__class__"])
Traceback (most recent call last):
type(Person.__dict__["__class__"])
~~~~~~~~~~~~~~~^^^^^^^^^^^^^
KeyError: '__class__'
Dunder attributes. It's interesting to note that there are 2 categories of __dunder__ attributes (those that start and end by "__").
- On one hand we have those like the ones we've just seen, these are Special Attributes (Metadata), that are used to store metadata: __name__, __class__, __bases__, __mro__, __dict__, __module__, __doce__, __annotations__.
- And on the other hand we have Special Methods (Behavioral Hooks), that are used to implement Python's syntactic sugar:
__call__: ob(), Invokation __getitem__: ob[key] __setitem__: ob[key] = value __getattr__: Fallback for missing attributes __getattribute__: Intercepts all attribute access __iter__, __next__: Iteration __str__, __repr__: String representation __eq__, __lt__, etc: Comparisons __enter__, __exit__: Context managers __add__, __mul__, etc: Arithmetic operations
Notice that if you access a Behavioral Hook "on your own" (I mean, you explicitly do: obj.__call__() or obj.__iter__()) the normal look up mechanism applies (using the object and its class). However, when used in the intended way (when you do obj(), or iter(obj)) the look up is done only in the class of the object (and if an object is a class it's done in its metaclass) not in the object itself.