Python data model & descriptors
What a descriptor is and where it lives
A descriptor is any object that defines __get__, __set__, or __delete__ and is stored as a class variable. When you read obj.attr and attr on the class is a descriptor, Python does not hand you the descriptor object. It calls the descriptor's __get__ and returns the result.
class Ten:
def __get__(self, obj, owner=None):
return 10
class C:
value = Ten() # a descriptor, living on the class
print(C().value) # 10 (calls Ten.__get__, not the Ten object)
The rule people miss: a descriptor only works as a class attribute. Put the same object into an instance dict and the protocol never fires, because the lookup machinery scans type(obj).__mro__ for descriptors, never the instance. @property is a descriptor you already use, packaging a getter and optional setter as an object with __get__/__set__ on the class. A plain function is a descriptor too: its __get__ binds it to the instance to produce a bound method.
getattr versus getattribute
Two hooks whose names differ by one word do very different jobs. __getattribute__ runs on every attribute access and drives the whole lookup. __getattr__ is a fallback, invoked only when that normal lookup raises AttributeError.
class G:
def __init__(self): self.real = 1
def __getattr__(self, name):
return f"fallback:{name}"
g = G()
print(g.real) # 1 (found normally; __getattr__ never runs)
print(g.missing) # fallback:missing (lookup failed, so the fallback runs)
Wrong "__getattr__ intercepts every attribute I read." It sees only the misses. Use it for lazy or virtual attributes. Reach for __getattribute__ only when you must observe every access, and then delegate to object.__getattribute__ inside it or the method recurses into itself forever.
Writing a context manager two ways
__enter__ runs at the top of a with block and its return value binds to the as target. __exit__ runs on the way out whether or not the body raised.
import time
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self # bound to `as`
def __exit__(self, exc_type, exc_value, tb):
self.elapsed = time.perf_counter() - self.start
# returning None lets any exception in the body propagate
The contextlib shortcut decorates a generator that yields exactly once. Code before the yield is __enter__, the yielded value is the as target, code after it is __exit__.
from contextlib import contextmanager
@contextmanager
def tag(name):
print(f"<{name}>")
try:
yield name.upper() # value bound to `as`; the body runs here
finally:
print(f"</{name}>") # runs even if the body raised
Wrap the yield in try/finally so cleanup still runs when the body raises, because an unhandled exception is re-raised inside the generator at the point of the yield.
Data versus non-data descriptor precedence
The lookup order for obj.attr, highest priority first: data descriptors on type(obj).__mro__, then the instance __dict__, then non-data descriptors and plain class attributes, then __getattr__. A data descriptor defines __set__ and/or __delete__; a non-data descriptor defines only __get__. That one distinction decides what an instance attribute can shadow.
class Data: # __set__ present -> data descriptor
def __get__(self, o, t=None): return "descriptor"
def __set__(self, o, v): pass
class NonData: # only __get__ -> non-data
def __get__(self, o, t=None): return "descriptor"
class C:
a = Data(); b = NonData()
c = C()
c.__dict__["a"] = "instance"
c.__dict__["b"] = "instance"
print(c.a) # descriptor (data descriptor outranks the instance dict)
print(c.b) # instance (instance dict outranks the non-data descriptor)
Of these descriptor tools, only @property and the slot members that __slots__ creates are data descriptors. Methods, classmethod, and staticmethod define just __get__, so they are non-data and a same-named instance attribute shadows them. A read-only property is still a data descriptor: define __set__ to raise AttributeError, and the entry stays protected even though it has no working setter.
The exit return value that swallows exceptions
__exit__(self, exc_type, exc_value, tb) runs on every exit. If the body raised, it receives the exception triple; if not, all three arguments are None. The return value is where correctness lives: a truthy return suppresses the exception, a falsy one (including None) lets it propagate.
Wrong "I return True from __exit__ so the block is safe." A blanket truthy return swallows every exception the body can raise, the ValueError you never expected and even a KeyboardInterrupt, because the with statement catches BaseException. Right return None, or omit the return, and suppress only after checking exc_type.
The @contextmanager form obeys the same rule through the generator. To log an error and still let it surface you have to re-raise it after the yield; catching it and not re-raising is what suppresses it. A generator that never yields raises RuntimeError with the message generator didn't yield, and one that yields twice raises generator didn't stop.
Metaclasses versus init_subclass and set_name
type is the default metaclass, so a class is an instance of its metaclass: type(obj) is the class, type(SomeClass) is the metaclass. The three-argument call type(name, bases, namespace) builds a class the same way a class statement does. A custom metaclass subclasses type and overrides __new__ to build the class object or __init__ to configure it, and its __call__ fires when the class is instantiated, which is the hook for singletons or instance caching.
class Meta(type):
def __new__(mcls, name, bases, ns, **kw):
ns["injected"] = 42
return super().__new__(mcls, name, bases, ns)
class Uses(metaclass=Meta): pass
print(Uses.injected) # 42
print(type(Uses)) # <class '__main__.Meta'>
Most subclass registration or validation needs no metaclass at all. __init_subclass__ (3.6) is an implicit classmethod that runs on the parent each time a subclass is defined, and __set_name__ (3.6) lets a descriptor learn the name it was bound to. Both fire only during class creation, inside type.__new__, so a descriptor attached to a class afterward never gets __set_name__ called for it.
Special-method lookup skips the instance
For implicit operations, Python looks the dunder up on the type and bypasses the instance dict. len(x) resolves to type(x).__len__, so a __len__ assigned onto the instance is ignored.
class C: pass
c = C()
c.__len__ = lambda: 5
len(c) # TypeError: object of type 'C' has no len()
c.__len__() # 5 (an explicit call does read the instance attribute)
The same holds for every operator-backed protocol: __enter__/__exit__ for with, __iter__ for iteration, __add__ for +. These have to be defined on the class, and patching one onto a single instance leaves the corresponding operator unaffected.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.