Visual walkthrough — Properties — `@property`, `@setter`, `@deleter` for controlled access
This unpacks [[2.1.07 Properties — @property, @setter, @deleter for controlled access (Hinglish)|the parent topic]]
and leans on Encapsulation and Access Modifiers, Descriptors __get__ __set__ __delete__,
and Class vs Instance Attributes.
Step 0 — Before anything: __getattribute__ runs first
WHAT. The very first thing that happens when you type obj.x is not the descriptor search.
Python calls a hidden method ==__getattribute__== — the single gatekeeper for every attribute
read. It is that method which then runs the ladder we build below. If the whole ladder fails and
raises AttributeError, Python calls a fallback method ==__getattr__== (only-on-failure).
WHY. A reader might think "descriptor lookup is step one." It is not — it lives inside
__getattribute__. Knowing this prevents the false belief that you can't intercept lookup earlier
(you can, by overriding __getattribute__), and explains where __getattr__ fits.
PICTURE. One door (__getattribute__) that every read passes through; it runs the ladder; only
if the ladder throws does the emergency exit __getattr__ open.

Step 1 — What "storage" even means: dictionaries along the MRO
WHAT. __getattribute__ needs places to look. Every object carries a private notebook, its
instance dictionary obj.__dict__. Its class has a notebook too — but a class can inherit
from base classes, so there is a chain of class notebooks: the MRO (Method Resolution
Order), the exact ordered list of classes Python searches. A dictionary here is a lookup table:
name → value.
WHY. The simplification "one class notebook" is not quite true: Python walks
type(obj).__mro__ — this class, then its parents, in order — checking each one's __dict__ until
it finds the name. You cannot understand who wins until you know there are many class notebooks
plus one instance notebook.
PICTURE. The MRO is a stack of class notebooks (child on top, then parents); beside it sits the single instance notebook.

Recall
Why "MRO" and not just "the class dict"? ::: A class can inherit; Python searches this class then each base in __mro__ order, so a property defined on a parent still counts.
Step 2 — A plain attribute: the instance notebook wins
WHAT. With no property anywhere in the MRO, obj.x looks up the name x. __getattribute__
scans the MRO for a special x, finds nothing, then reads x straight out of obj.__dict__.
WHY. This is the baseline the parent note calls "start with a plain attribute." No function runs — you get the stored object back. We show it first so the change in Step 6 is obvious.
PICTURE. The arrow scans the class chain, finds no x, drops to the instance notebook, returns
the raw value 25.

Here obj.__dict__['x'] means "the entry named x in this object's personal notebook" — and
nothing runs, so setting obj.x = -300 would also succeed silently. That is the danger.
Step 3 — A descriptor: an object that reacts to lookup
WHAT. A descriptor is any object that lives in a class notebook (somewhere in the MRO)
and defines at least one of three special methods: __get__, __set__, __delete__. When the
scan finds such an object, it does not hand it back — it calls the matching method.
WHY. This is the mechanism that lets a value "react." A plain integer can't run code when read; a descriptor can. Properties are built on this. See Descriptors __get__ __set__ __delete__.
PICTURE. The three slots of a descriptor and which access each one intercepts.

Step 4 — Deletion has its own routing (del obj.x)
WHAT. Writes and deletes travel a parallel path via __setattr__/__delattr__. For del obj.x, Python asks: is x on the class (in the MRO) a data descriptor with __delete__? If
so, call descriptor.__delete__(obj). Only if there is no such data descriptor does del fall
through to removing x from obj.__dict__.
WHY. A reader might assume del obj.x always erases the instance entry. Not with a property: a
property is a data descriptor, so del obj.x is hijacked into your @x.deleter — which is why
the parent note can turn del acc.balance into a controlled "close account" action instead of a
raw wipe.
PICTURE. Two exits for del: with a data-descriptor property → __delete__ runs; without one →
the instance entry is popped.

property.__delete__(obj)— routes to your@x.deleter; if you defined none, it raisesAttributeError.del obj.__dict__['x']— the raw fallback that only happens when no data descriptor guardsx.
Step 5 — The lookup order (the ranking rule)
WHAT. Here is the exact order __getattribute__ uses for obj.x. Read it as a priority list —
the first rank that matches, wins. "Class" here means "found anywhere along the MRO."
WHY. In Step 2 there was no descriptor, so rank 1 was empty and the instance (rank 2) won — that's why plain attributes behave normally. The magic of the next step is that a property fills rank 1, jumping above the instance notebook.
PICTURE. A ladder: the search climbs down from the top rung, stopping at the first rung that
holds an x.

Each rung means:
- rank 1 — is there an
xanywhere in the MRO that has__set__/__delete__? If so, call it. Done. - rank 2 — otherwise, is
xin this object's own notebook? Return it. - rank 3 — otherwise a read-only descriptor (
__get__only) in the MRO? - rank 4 — otherwise a plain class attribute (e.g. a method, a shared constant).
- else — raise
AttributeError, which hands off to__getattr__(Step 0).
Step 6 — Install a property: rank 1 fills up
WHAT. Now we put a property object into the class notebook under the name x. Because it
defines __set__, it is a data descriptor → it occupies rank 1.
WHY. This is the central result forming. Reading obj.x now hits rank 1 first, so
property.__get__ runs your getter. Writing obj.x = v hits property.__set__, which runs your
setter — validation and all.
PICTURE. Same ladder as Step 5, but rank 1 is now occupied. The search stops at the top rung and never reaches the instance notebook.

property.__set__— the rank-1 write hook. It cannot be skipped from outside.your setter runs— this is whereif value < -273.15: raiselives.
Step 7 — The edge case: sneaking into the instance notebook
WHAT. The natural attack: "What if I bypass the setter and jam x straight into the instance
notebook?" — e.g. obj.__dict__['x'] = -300 (going around __setattr__). Does that beat the
property?
WHY. This is exactly the scenario the parent note calls the guarantee. A reader might assume the instance (rank 2) could override the class. It can't — because a data descriptor is rank 1, strictly above rank 2.
PICTURE. The value -300 sits inside the instance notebook, but the read still stops at rank 1
and calls the getter. The smuggled value is shadowed — invisible on read.

Step 8 — The degenerate case: no setter means no __set__? (it still blocks)
WHAT. A read-only property (only @property, no @setter). Subtle point: it still defines
__set__ — but that __set__ immediately raises AttributeError.
WHY. A reader might guess "no setter ⇒ not a data descriptor ⇒ falls to rank 2 ⇒ writing
lands in the instance dict." Wrong. The property type always implements __set__; when you
gave no fset, that method's job is to refuse. So it stays rank 1 and blocks the write instead
of silently storing it.
PICTURE. The write arrow hits rank 1's __set__, which throws — it never reaches the instance
notebook.

This is why acc.balance = 50 raises rather than quietly creating acc.__dict__['balance'].
The one-picture summary
Everything compressed: obj.x enters through __getattribute__, which runs the ladder over the MRO
plus the instance dict. A data-descriptor property occupies the top rung, so read → getter,
write → setter (or refusal), delete → deleter — and the instance notebook below is always
shadowed. That single geometry is the guarantee of controlled access.

Recall Feynman retelling — the whole walkthrough in plain words
Every time you type obj.x, one gatekeeper — __getattribute__ — opens the door and runs a
search; only if that whole search fails does an emergency method, __getattr__, get a turn.
The search looks in two kinds of places: your object's personal notebook, and a chain of class
notebooks (this class, then its parents, in MRO order). Normally it finds nothing special in the
class chain and just reads the value out of your personal notebook — plain and unguarded. Now we
drop a special reactive object called a property into the class notebook. Because it can
react to writes, Python promotes it to the top of the priority ladder — above your personal
notebook. From now on, reading runs the getter, writing runs the setter (which validates), and
even deleting is hijacked into your deleter instead of erasing the entry. If a sneaky person
crams a bad value straight into your personal notebook, nobody sees it: the read stops at the top
rung and never looks down. And if you never wrote a setter, the property doesn't vanish — its
built-in __set__ simply slams the door with an error. That top-rung position, held because a
property is a data descriptor, is the entire reason controlled access can't be dodged.
Recall
What runs first on any obj.x, before the ladder? ::: __getattribute__ — it always runs and contains the ladder; __getattr__ runs only if the ladder raises AttributeError.
Does Python search only type(obj).__dict__? ::: No — it walks the whole MRO (type(obj).__mro__), checking each class's dict in order.
Which rung (rank) does a property occupy, and why? ::: Rank 1 (data descriptor) — it defines __set__, so it sits above the instance dict.
Where does del obj.x go when x is a property? ::: To the property's __delete__ (your @x.deleter), not to popping obj.__dict__['x'].
Why can't smuggling a value into obj.__dict__ bypass a property? ::: Reads stop at rank 1 (the property's getter) before ever reaching rank 2 (the instance dict).
Does a read-only property lack __set__? ::: No — property always defines __set__; with no fset it raises AttributeError, keeping it rank 1.
For a plain method (non-data descriptor), does the instance dict override it? ::: Yes — a same-named instance entry (rank 2) beats a non-data descriptor (rank 3).