Everything You Don't Need to Know About Classes

Function or class?

☝️ function      ✌️ class


>>> print
<built-in function print>
>>> str
<class 'str'>
>>> sorted
<built-in function sorted>
>>> reversed
<class 'reversed'>
>>> type
<class 'type'>
>>> enumerate
<class 'enumerate'>
>>> list
<class 'list'>
          

"function" usually means callable

Function or class?

Many built-in "functions" are classes

☝️ function      ✌️ class


>>> print
<built-in function print>
>>> str
<class 'str'>
>>> sorted
<built-in function sorted>
>>> reversed
<class 'reversed'>
>>> type
<class 'type'>
>>> enumerate
<class 'enumerate'>
>>> list
<class 'list'>
          

"function" usually means callable

Everything You Don't Need to Know About Classes

self__init__dunder methodsobjectclass attributeshashabilitysuper()immutabilityimplementing operatorsmultiple inheritanceproperty__dict__name manglingbound methods__new__abstract base classes__slots__typecallable objects__getattribute____getattr__descriptorsgoose typingmetaclasses__mro_entries__

>>> numbers = [2, 1, 3, 4]
>>> numbers.append(7)
>>> list.append(numbers, 11)
>>> numbers
[2, 1, 3, 4, 7, 11]
          

>>> name = "PyBeach"
>>> name.lower()
'pybeach'
>>> str.lower(name)
'pybeach'
          

>>> sorted(["Banana", "apple", "Cherry"], key=str.lower)
['apple', 'Banana', 'Cherry']
          

Methods are just functions attached to classes


>>> name = "PyBeach"
>>> name.lower()
'pybeach'
>>> type(name)
<class 'str'>
>>> type(name).lower
<method 'lower' of 'str' objects>
>>> type(name).lower(name)
'pybeach'
          
class Album:

    def __init__(self, title, tracks):
        self.title = title
        self.tracks = tracks

def track(self, number):
    return self.tracks[number - 1]

Album.track = track

>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> plans.track(1)
Track('Soul Meets Body', 231)
            
class Album:

    def __init__(self, title, tracks):
        self.title = title
        self.tracks = tracks

    def track(self, number):
        return self.tracks[number - 1]

>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> plans.track(1)
Track('Soul Meets Body', 231)
>>> Album.track(plans, 1)
Track('Soul Meets Body', 231)
            
class Album:

    def __init__(self, title, tracks):
        self.title = title
        self.tracks = tracks

    def track(self, number):
        return self.tracks[number - 1]
class Album:

    def __init__(this, title, tracks):
        this.title = title
        this.tracks = tracks

    def track(this, number):
        return this.tracks[number - 1]
class Album:

    def __init__(self, title, tracks):
        self.title = title
        self.tracks = tracks

    def track(number):
        return self.tracks[number - 1]

>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> plans.track(1)
TypeError: Album.track() takes 1 positional argument but 2 were given
            

Every method call goes through the class

thing.method(arguments)

⬇

type(thing).method(thing, arguments)

double underscore


class Album:

    def __init__(self, title, tracks):
        self.title = title
        self.tracks = tracks

    def __repr__(self):
        return f"Album({self.title!r}, {self.tracks!r})"
          
__init__
constructor initializer

🧐  a particularly pedantic Python programmer

1
__new__
the constructor
2
__init__
the initializer
a new Albumits __dict__
title'Plans'
tracks[...]
__init__ __new__ __del__ __eq__ __ne__ __lt__ __gt__ __le__ __ge__ __hash__ __repr__ __str__ __bool__ __int__ __float__ __bytes__ __complex__ __format__ __enter__ __exit__ __len__ __iter__ __getitem__ __setitem__ __delitem__ __contains__ __reversed__ __next__ __missing__ __length_hint__ __call__ __add__ __radd__ __sub__ __rsub__ __mul__ __rmul__ __truediv__ __rtruediv__ __mod__ __rmod__ __floordiv__ __rfloordiv__ __pow__ __rpow__ __matmul__ __rmatmul__ __and__ __rand__ __or__ __ror__ __xor__ __rxor__ __rshift__ __rrshift__ __lshift__ __rlshift__ __neg__ __pos__ __invert__ __divmod__ __rdivmod__ __abs__ __index__ __round__ __trunc__ __floor__ __ceil__ __iadd__ __isub__ __imul__ __itruediv__ __imod__ __ifloordiv__ __ipow__ __imatmul__ __iand__ __ior__ __ixor__ __irshift__ __ilshift__ __getattribute__ __getattr__ __setattr__ __delattr__ __dir__ __set_name__ __get__ __set__ __delete__ __init_subclass__ __mro_entries__ __class_getitem__ __prepare__ __instancecheck__ __subclasscheck__ __await__ __aenter__ __aexit__ __aiter__ __anext__ __buffer__ __release_buffer__ __post_init__ __replace__ __copy__ __deepcopy__ __getnewargs__ __getnewargs_ex__ __getstate__ __reduce__ __reduce_ex__ __setstate__ __fspath__ __sizeof__ __subclasses__ __subclasshook__

Dunder methods are a contract

you write
Python calls

>>> plans == Album("Plans", tracks)
True
            
    def __eq__(self, other):
        return self.tracks == other.tracks

>>> len(plans)
2
            
    def __len__(self):
        return len(self.tracks)

>>> for track in plans:
...     print(track.title)
...
Soul Meets Body
Summer Skin
            
    def __iter__(self):
        return iter(self.tracks)

Where do attributes live?

 

>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> plans.title
'Plans'
>>> plans.__dict__
{'title': 'Plans', 'tracks': [...]}
>>> plans.__dict__["title"]
'Plans'
>>> plans.__dict__["title"] = "Transatlanticism"
>>> plans.title
'Transatlanticism'
>>> plans.title = "Narrow Stairs"
>>> plans.year = 2005
>>> del plans.year
>>> plans.title = "Plans"
plans.__dict__the instance
title'Plans''Transatlanticism''Narrow Stairs''Plans'
tracks[...]
year2005
 
inside the class
self.title = title
outside the class
plans.title = "Plans"

 

class Track:

    __slots__ = ("title", "seconds")

    def __init__(self, title, seconds):
        self.title = title
        self.seconds = seconds
with __slots__fixed slots✗ no __dict__
title
'Summer Skin'
seconds
194
artist
✗
without __slots__a dictionary
title'Summer Skin'
seconds194
>>> t = Track("Summer Skin", 194)
>>> t.__dict__
AttributeError: 'Track' object has no attribute '__dict__'.
Did you mean '.__dir__' instead of '.__dict__'?
>>> t.artist = "Death Cab for Cutie"
AttributeError: 'Track' object has no attribute 'artist'
and no __dict__ for setting new attributes
88 bytes per Track with __slots__ 128 bytes per Track without

What about class attributes?

class Album:

    genre = "unknown"

    def __init__(self, title, tracks):
        ...
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> plans.genre
'unknown'
>>> "genre" in plans.__dict__
False
>>> "genre" in Album.__dict__
True
Album.__dict__the class
genre'unknown'
trackfunction
 
 
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
genre✗ not here
 

What about properties?

class Album:
    ...
    @property
    def duration(self):
        return sum(t.seconds for t in self.tracks)
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> plans.duration
425
>>> "duration" in plans.__dict__
False
>>> "duration" in Album.__dict__
True
>>> Album.duration
<property object at 0x7ef6b3ecbe50>
Album.__dict__the class
genre'unknown'
trackfunction
durationproperty
 
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
duration✗ not here
 

How do properties work?

A descriptor: an object with __get__

class Album:
    ...
    @property
    def duration(self):
        return sum(t.seconds for t in self.tracks)
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> plans.duration
425
>>> "duration" in plans.__dict__
False
>>> "duration" in Album.__dict__
True
>>> Album.duration
<property object at 0x7ef6b3ecbe50>
>>> Album.duration.__get__(plans, Album)
425
Album.__dict__the class
genre'unknown'
trackfunction
durationproperty__get__
 
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
duration✗ not here
 

property is a class

class property:

    def __init__(self, function):
        self.function = function

    def __get__(self, instance, owner):
        return self.function(instance)

    # Plus __set__, setter, and more...
    # (we'll see __set__ soon)

How we usually think of attribute lookups

attributelookup on the object?return that valueyes on the class?return that valuenoyes AttributeErrorno

How we usually think of attribute lookups

That's not quite right either

the class, then the object

attributelookup on the object?return that valueyes on the class?no AttributeErrorno valuehas __get__?yes call its __get__yes return itno

The class comes first

class Album:
    ...
    @property
    def duration(self):
        return sum(t.seconds for t in self.tracks)
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> plans.__dict__["duration"] = 0
Album.__dict__the class
genre'unknown'
trackfunction
durationproperty__get__
 
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
duration0
 

The class comes first

A data descriptor: __get__ and __set__

class Album:
    ...
    @property
    def duration(self):
        return sum(t.seconds for t in self.tracks)
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> plans.__dict__["duration"] = 0
>>> plans.duration
425
Album.__dict__the class
genre'unknown'
trackfunction
durationproperty__get____set__
 
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
duration0skipped!
 

How attribute lookups really work

attributelookup on the class? valuehas __set__?yescall its __get__yes on the object?return that valuenonoyes on the class?noAttributeErrorno valuehas __get__?yescall its __get__yes return itno propertya data descriptor cached_propertynot a data descriptor,the first time cached_propertyevery time after that

Roughly object.__getattribute__


class object:
    def __getattribute__(self, name):
        cls_attr = MISSING
        for cls in type(self).__mro__:
            if name in cls.__dict__:
                cls_attr = cls.__dict__[name]
                break
        get = getattr(type(cls_attr), "__get__", None)
        if get and hasattr(type(cls_attr), "__set__"):
            return get(cls_attr, self, type(self))
        if name in self.__dict__:
            return self.__dict__[name]
        if get:
            return get(cls_attr, self, type(self))
        if cls_attr is not MISSING:
            return cls_attr
        raise AttributeError(name)
          

There's one descriptor you've used in every class you've made

Every object has a type

Every function is a descriptor

class Album:
    ...
    def track(self, number):
        return self.tracks[number - 1]
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> type(Album.track)
<class 'function'>
>>> hasattr(type(Album.track), "__get__")
True
Album.__dict__the class
genre'unknown'
trackfunction__get__
durationproperty__get____set__
 
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
 
 

Looking up a method

__get__ returns the bound method

class Album:
    ...
    def track(self, number):
        return self.tracks[number - 1]
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> type(Album.track)
<class 'function'>
>>> hasattr(type(Album.track), "__get__")
True
>>> plans.track
<bound method Album.track of Album('Plans', [...])>
>>> Album.track.__get__(plans, Album)
<bound method Album.track of Album('Plans', [...])>
Album.__dict__the class
genre'unknown'
trackfunction__get__
durationproperty__get____set__
 
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
track✗ not here
 

Remember this?

Methods work because every function is a descriptor

thing.method(arguments)

⬇

type(thing).method(thing, arguments)

⬇

type(thing).method.__get__(thing, type(thing))(arguments)

Powered by descriptors

propertycached_propertymethods classmethodstaticmethod__slots__

You probably don't need to
write a descriptor

Just use a property or a cached_property instead

Method Resolution Order

Joffrey, a Siamese cat, resting his head on a couch cushion
Joffrey

Method Resolution Order

Joffrey, a Siamese cat, resting his head on a couch cushion
Joffrey

Method Resolution Order

>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> Album.__mro__
(<class '__main__.Album'>, <class 'object'>)
>>> plans.__mro__
AttributeError: 'Album' object has no attribute '__mro__'
>>> "__mro__" in Album.__dict__
False
Album.__dict__a class
genre track duration
longest single ...
__mro__✗ not here
plans.__dict__an instance
title tracks
__mro__✗ not here

type == class

Everything has a type

type is the type of all types


>>> type
<class 'type'>
>>> type(type)
<class 'type'>
          
type strintlistAlbum type

type is a metaclass

The type of an object is its class

The type of a class is its metaclass

>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> type(plans)
<class '__main__.Album'>
>>> type(Album)
<class 'type'>
>>> type.__dict__["__mro__"]
<attribute '__mro__' of 'type' objects>
type.__dict__a metaclass
__call__method
__mro__descriptor__get____set__
Album.__dict__a class
genre track duration
longest single ...
__mro__✗ not here
plans.__dict__an instance
title tracks
__mro__✗ not here

Callable objects

Classes are callable

Their class has a __call__ method

Their metaclass has a __call__ method

>>> from functools import partial
>>> partial
<class 'functools.partial'>
>>> to_int = partial(int, base=2)
>>> to_int("101")
5
>>> type(to_int).__call__(to_int, "101")
5
>>> Album("Plans", [])
Album('Plans', [])
>>> type(Album).__call__(Album, "Plans", [])
Album('Plans', [])

class type:

    def __call__(cls, *args, **kwargs):
        obj = cls.__new__(cls, *args, **kwargs)
        if isinstance(obj, cls):
            cls.__init__(obj, *args, **kwargs)
        return obj
          

Powered by metaclasses

the standard library

enumabccollections.abc

third-party libraries

djangopydanticsqlalchemy

You probably don't need a metaclass

class Album:
    def __new__(cls, *args, **kwargs):
        ...
class Album:
    def __init_subclass__(cls, **kwargs):
        ...
@decorator
class Album:
    ...

super()

in 2 minutes

class Parent:
    def hi(self):
        return "parent"

class Child(Parent):
    def hi(self):
        return "child + " + super().hi()
>>> child = Child()
>>> super
<class 'super'>
>>> super(Child, child).hi()
'parent'
>>> type(child).__mro__
(<class '__main__.Child'>,
 <class '__main__.Parent'>,
 <class 'object'>)
class Child:
    def __init__(self):
        print(locals())
        super().__init__()
>>> child = Child()
{'self': <__main__.Child object at 0x7994f82a0830>,
 '__class__': <class '__main__.Child'>}
class Child:
    def __init__(self):
        print(locals())
        super().__init__()
>>> child = Child()
{'self': <__main__.Child object at 0x7994f82a0830>,
 '__class__': <class '__main__.Child'>}
>>> Child.__init__.__code__.co_freevars
('__class__',)
class TrackNotFound(LookupError):
    pass

def __init__(self, number):
    super().__init__(f"No track number {number}")
    self.number = number

TrackNotFound.__init__ = __init__
>>> TrackNotFound(5)
RuntimeError: super(): __class__ cell not found

Methods are just functions attached to classes*

trey.io/super

Sometimes functions are actually classes

Sometimes classes are actually functions


>>> import typing
>>> class Point(typing.NamedTuple):
...     x: int
...
>>> typing.NamedTuple
<function NamedTuple at 0x7ef6b3f0b880>
>>> typing.NamedTuple.__mro_entries__
<function _namedtuple_mro_entries at 0x7ef6b3f0b920>
          

Want more weird Python features?

follow me online

a Python tip every Wednesday

Python Morsels

not just for beginners

Truthful Technology

team training

Resources: trey.io/PyBeach2026

follow me online

a Python tip every Wednesday

Python Morsels

not just for beginners

Truthful Technology

team training

Cutting room floor

Slides cut from the talk. Not part of it.

Everything You Don't Need
to Know About Classes

multiple inheritancesuper()

super() in 2 minutes

Metaclasses power a lot of things

you write
Python calls
for color in Color:
    print(color)
class EnumType(type):
    def __iter__(cls):
        ...
Thing()what happens when you call a class? thing.method(x)no self passed in def method(self, x):but self is accepted thing.attributewhere does Python find it?

Metaclasses power a lot of things

you write
Python calls
for color in Color:
    print(color)
class EnumType(type):
    def __iter__(cls):
        ...
isinstance(plans, Iterable)
class ABCMeta(type):
    def __instancecheck__(cls, obj):
        ...

Over 100 dunder methods

x == y __eq__ x < yx >= y __lt____le____gt____ge__ with x: __enter____exit__ len(x)x[i]for v in x __len____getitem____setitem__
__delitem____iter____contains__
x + yx * y __add____sub____mul____truediv__
__floordiv____mod____pow____neg__

Everything You Don't Need to Know About Classes

It's easier to look something up when you know it exists

The type of a class is its metaclass

Method Resolution Order

>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> Album.__mro__
(<class '__main__.Album'>, <class 'object'>)
>>> plans.__mro__
AttributeError: 'Album' object has no attribute
'__mro__'
>>> type.__dict__["__mro__"]
<attribute '__mro__' of 'type' objects>
type.__dict__a metaclass
__call__method
__mro__descriptor__get____set__
Album.__dict__a class
genre track duration
longest single ...
__mro__✗ not here
plans.__dict__an instance
title tracks
__mro__✗ not here

Method Resolution Order

Classes are callable

>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> Album.__mro__
(<class '__main__.Album'>, <class 'object'>)
>>> plans.__mro__
AttributeError: 'Album' object has no attribute
'__mro__'
>>> type.__dict__["__mro__"]
<attribute '__mro__' of 'type' objects>
>>> type(Album).__call__(Album, "Plans", [])
Album('Plans', [])
type.__dict__a metaclass
__call__method
__mro__descriptor__get____set__
Album.__dict__a class
genre track duration
longest single ...
__mro__✗ not here
plans.__dict__an instance
title tracks
__mro__✗ not here

Metaclasses power a lot of things


>>> import enum
>>> class Color(enum.Enum):
...     RED = 1
...     BLUE = 2
...
>>> list(Color)
[<Color.RED: 1>, <Color.BLUE: 2>]
>>> type(Color)
<class 'enum.EnumType'>
>>> "__iter__" in type(Color).__dict__
True
          

Abstract base classes

Goose typing

using type checks for duck typing


>>> from collections.abc import Iterable
>>> type(Iterable)
<class 'abc.ABCMeta'>
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> isinstance(plans, Iterable)
True
>>> Album.__mro__
(<class '__main__.Album'>, <class 'object'>)
>>> "__iter__" in Album.__dict__
True
              

ABCMeta implements

__instancecheck__
__subclasscheck__

Metaclasses customize classes


>>> class Meta(type):
...     @classmethod
...     def __prepare__(metacls, name, bases):
...         return {"surprise": "I was never defined!"}
...
>>> class Weird(metaclass=Meta):
...     print(surprise)
...
I was never defined!
          

Resources   trey.io/PyBeach2026

Thank you

Trey Hunner
Python Team Trainer
trey@PythonMorsels.com

Methods live on the class

class Album:
    ...
    def track(self, number):
        return self.tracks[number - 1]
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> Album.track
<function Album.track at 0x7ef6b3ee5b20>
>>> plans.track
<bound method Album.track of Album('Plans', [...])>
Album.__dict__the class
genre'unknown'
trackfunction
durationproperty__get____set__
 
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
track✗ not here
 

Where did the bound method come from?

Functions are descriptors

class Album:
    ...
    def track(self, number):
        return self.tracks[number - 1]
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> Album.track
<function Album.track at 0x7ef6b3ee5b20>
>>> plans.track
<bound method Album.track of Album('Plans', [...])>
>>> hasattr(type(Album.track), "__get__")
True
>>> Album.track.__get__(plans)
<bound method Album.track of Album('Plans', [...])>
Album.__dict__the class
genre'unknown'
trackfunction__get__
durationproperty__get____set__
 
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
track✗ not here
 

How does a cached property cache?

Only data descriptors come first

class Album:
    ...
    @cached_property
    def longest(self):
        return max(self.tracks, key=lambda t: t.seconds)
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> plans.longest
Track('Soul Meets Body', 231)
>>> plans.longest
Track('Soul Meets Body', 231)
Album.__dict__the class
genre'unknown'
trackfunction
durationproperty__get____set__
longestcached_property__get__
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
longestTrack(...)
 
1 the class, if it's a data descriptor (duration)
2 the instance's __dict__ (title, longest)
3 the class, anything else (genre, track)

Powered by descriptors

class Album:
    ...
    @property
    def duration(self):
        return sum(t.seconds for t in self.tracks)

    @cached_property
    def longest(self):
        return max(self.tracks, key=lambda t: t.seconds)

    @classmethod
    def single(cls, track):
        return cls(track.title, [track])
Album.__dict__the class
genre'unknown'
trackfunction__get__
durationproperty__get__
longestcached_property__get__
singleclassmethod__get__
also __init__ __repr__ __len__ __iter__

A cached property shadows itself

class cached_property:
    ...
    def __get__(self, instance, owner=None):
        value = self.func(instance)
        instance.__dict__[self.name] = value
        return value
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> plans.longest
Track('Soul Meets Body', 231)
>>> plans.longest
Track('Soul Meets Body', 231)
Album.__dict__the class
genre'unknown'
trackfunction__get__
durationproperty__get____set__
longestcached_property__get__
singleclassmethod__get__
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
longestTrack(...)
 

What about properties?

property functools.cached_property a virtual attribute a lazy attribute

A lazy attribute

from functools import cached_property

class Album:
    ...
    @cached_property
    def longest(self):
        return max(self.tracks, key=lambda t: t.seconds)
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> plans.longest
Track('Soul Meets Body', 231)
>>> plans.longest
Track('Soul Meets Body', 231)
Album.__dict__the class
genre'unknown'
trackfunction
longestcached_property
 
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
longestTrack(...)
 

But how does this actually work?

The Descriptor Protocol

from functools import cached_property

class Album:
    ...
    @cached_property
    def longest(self):
        return max(self.tracks, key=lambda t: t.seconds)
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> cached_property
<class 'functools.cached_property'>
>>> Album.__dict__["longest"]
<functools.cached_property object at 0x7f3c1d2b8e90>
Album.__dict__the class
genre'unknown'
trackfunction
longestcached_property
 
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
 
 

The Descriptor Protocol

A descriptor: an object with __get__

from functools import cached_property

class Album:
    ...
    @cached_property
    def longest(self):
        return max(self.tracks, key=lambda t: t.seconds)
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> cached_property
<class 'functools.cached_property'>
>>> Album.__dict__["longest"]
<functools.cached_property object at 0x7f3c1d2b8e90>
>>> plans.longest
Track('Soul Meets Body', 231)
>>> Album.__dict__["longest"].__get__(plans)
Track('Soul Meets Body', 231)
>>> "longest" in plans.__dict__
True
Album.__dict__the class
genre'unknown'
trackfunction
longestcached_property__get__
 
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
longest✗ not herelongestTrack(...)
 

How Python really looks up an attribute

1 the class, if it's a data descriptor (duration)
it has __set__
2 the instance's __dict__ (title, tracks)
3 the class, anything else (genre, track, longest)
calling __get__ if it has one
Album.__dict__the class13
genre'unknown'
trackfunction
durationproperty__get____set__
longestcached_property__get__
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance2
title'Plans'
tracks[...]
duration0
 
MISSING = sentinel("MISSING")

def __getattribute__(self, name):
    attr = MISSING
    for cls in type(self).__mro__:               # the class, then its parents
        if name in cls.__dict__:
            attr = cls.__dict__[name]
            break
    get = getattr(type(attr), "__get__", None)
    if get and hasattr(type(attr), "__set__"):   # ① a data descriptor
        return get(attr, self, type(self))
    if name in self.__dict__:                    # ② the instance's __dict__
        return self.__dict__[name]
    if get:                                      # ③ any other descriptor
        return get(attr, self, type(self))
    if attr is not MISSING:                      # ③ or a plain class attribute
        return attr
    raise AttributeError(name)

 

Every function is a descriptor object

class Album:
    ...
    def track(self, number):
        return self.tracks[number - 1]
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> type(Album.track)
<class 'function'>
>>> hasattr(type(Album.track), "__get__")
True
Album.__dict__the class
genre'unknown'
trackfunction__get__
durationproperty__get____set__
longestcached_property__get__
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
 
 

WHY???

class Album:
    ...
    def track(self, number):
        return self.tracks[number - 1]
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> type(Album.track)
<class 'function'>
>>> hasattr(type(Album.track), "__get__")
True
>>> Album.track
<function Album.track at 0x7ef6b3ee5b20>
>>> plans.track
<bound method Album.track of Album('Plans', [...])>
>>> Album.track.__get__(plans)
<bound method Album.track of Album('Plans', [...])>
Album.__dict__the class
genre'unknown'
trackfunction__get__
durationproperty__get____set__
longestcached_property__get__
 
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
 
 

"always passes self whether we like it or not"

class Album:
    ...
    @classmethod
    def single(cls, track):
        return cls(track.title, [track])
>>> tracks = [Track("Soul Meets Body", 231),
...           Track("Summer Skin", 194)]
>>> plans = Album("Plans", tracks)
>>> classmethod, staticmethod
(<class 'classmethod'>, <class 'staticmethod'>)
>>> hasattr(classmethod, "__get__")
True
>>> hasattr(staticmethod, "__get__")
True
Album.__dict__the class
genre'unknown'
trackfunction__get__
durationproperty__get____set__
longestcached_property__get__
singleclassmethod__get__
also __init__ __repr__ __len__ __iter__
plans.__dict__the instance
title'Plans'
tracks[...]
 
 

Remember __slots__?

class Track:

    __slots__ = ("title", "seconds")
    ...
>>> t = Track("Summer Skin", 194)
>>> Track.__dict__["title"]
<member 'title' of 'Track' objects>
>>> hasattr(Track.__dict__["title"], "__set__")
True
Track.__dict__the class
titlemember__get____set__
secondsmember__get____set__
ta Track, with fixed slots
title
'Summer Skin'
seconds
194
2:00