R

ReflexKR

Reflex Docs

Name Tag Airport: Python's Object Model

·

Course overview

1

A tag is not the bag: names and objects

2

Sealed bags: mutable and immutable

3

Bags inside bags: containers and copying

4

Handing over a bag: functions and arguments

5

Zones and passes: scope

6

Carry-on bags: closures

7

The bag spec: classes and instances

8

Standard airport procedures: dunder methods

9

The spec behind the spec: inheritance and MRO

10

The checkpoint: how attribute access really works

11

Unclaimed bags: memory and GC

12

Locker numbers: hashing, equality and the big picture

Name Tag Airport: Python's Object Model
Lesson 10

Time 7:30–8:20

The checkpoint: how attribute access really works

Lost-baggage report

LOST-010

A bag with negative weight

Reported by

Oh, maintenance engineer for the weighing system

What went missing

The scale records. A weight check was added, yet a bag weighing -10 kg shows up in the records.

To close the case

Find the route that bypassed the check, and make every route pass through the checkpoint.

Haneulseom International Airport · Baggage Center

To stop negative weights, Oh made Bag check the weight when a bag is created. Yet the records contain a bag weighing -10 kg. Supervisor Do Miran flips through the entry log. "We put a checkpoint at the main gate, and it came in through the side door."

Today we look at the checkpoint Python passes through the moment obj.x is read or written. It is also the full picture of the reading rule we called "instance, then class" in Lesson 7.

10-1. @property: putting a checkpoint on an attribute (9 min)

Here is the reporter's code. The check exists only in __init__.

class Bag:
    def __init__(self, kg):
        if kg < 0:
            raise ValueError("negative weight")
        self.kg = kg

b = Bag(20)
b.kg -= 30
print(b.kg)
# -10

The bag passed the check when it was created, but the later b.kg -= 30 writes straight into the instance __dict__. That is the side door. To put a check on writing the attribute itself, use @property.

class Bag:
    def __init__(self, kg):
        self.kg = kg
    @property
    def kg(self):
        return self._kg
    @kg.setter
    def kg(self, value):
        if value < 0:
            raise ValueError(f"negative weight: {value}")
        self._kg = value

b = Bag(20)
print(b.kg, vars(b))
# 20 {'_kg': 20}
b.kg -= 30
# ValueError: negative weight: -10
  • Reading b.kg calls the first kg function, and writing b.kg = v calls the @kg.setter function. From the outside it is used like a plain attribute.
  • self.kg = kg in __init__ goes through the setter too. The check needs to live in just one place.
  • The actual value is stored in _kg. The name kg is a property object that lives on the class, not on the instance.

Without a setter, the attribute is read-only. That suits values computed from other attributes.

class Box:
    def __init__(self, w, h):
        self.w, self.h = w, h
    @property
    def area(self):
        return self.w * self.h

x = Box(3, 4)
print(x.area, type(Box.__dict__["area"]).__name__)
# 12 property
x.area = 5
# AttributeError: property 'area' of 'Box' object has no setter

Pin says

→ A single underscore like _kg is only a 'please don't touch' agreement, not a lock. Nothing stops b._kg = -5. The checkpoint guards the normal route (b.kg = …).

10-2. Descriptors: putting the checkpoint in the spec (10 min)

The reporter's bag has not only a weight (kg) but also a length (cm). Copying a property for every attribute multiplies the checking code. Instead, you can build one checkpoint and install it on several attributes. That is a descriptor.

A descriptor is an object with at least one of __get__ and __set__ (or __delete__). Put it on a class as a class attribute, and Python calls those methods whenever that name is read or written on an instance.

class Positive:
    def __set_name__(self, owner, name):
        print(f"set_name: {owner.__name__}.{name}")
        self.name = name
    def __get__(self, obj, owner=None):
        if obj is None:
            return self
        return obj.__dict__[self.name]
    def __set__(self, obj, value):
        if value < 0:
            raise ValueError(f"{self.name} must be >= 0: {value}")
        obj.__dict__[self.name] = value

class Bag:
    kg = Positive()
    cm = Positive()
    def __init__(self, kg, cm):
        self.kg, self.cm = kg, cm
# set_name: Bag.kg
# set_name: Bag.cm

b = Bag(20, 55)
print(b.kg, b.cm, vars(b))
# 20 55 {'kg': 20, 'cm': 55}
print(type(Bag.kg).__name__)
# Positive
b.cm = -1
# ValueError: cm must be >= 0: -1
WhenWhat Python calls
The class has just been createdPositive.__set_name__(checkpoint, Bag, "kg")
Reading b.kgPositive.__get__(checkpoint, b, Bag)
Reading Bag.kgPositive.__get__(checkpoint, None, Bag)
Writing b.kg = 20Positive.__set__(checkpoint, b, 20)
  • __set_name__ (3.6) tells the descriptor the name it was attached to, the moment the class statement ends. That is why the same Positive can be installed on both kg and cm.
  • There is only one checkpoint object on the class, shared by every bag. So the values are written not on the checkpoint but in each bag's __dict__.
  • Even though the value went into __dict__ under the same name "kg", reading it goes through the checkpoint rather than __dict__. That ordering is the topic of 10-4.
  • @property is also a descriptor, one Python ships ready-made.

Tag board

Step 1 / 0

Next to run: 0

Preparing the lab…

Just changed

Sealed bag (immutable)

In the class Bag box, kg and cm are Positive checkpoints, each remembering its own name. On the last line, the kg value goes into the bag's __dict__, and cm = -1 is stopped at the checkpoint.

10-3. How a function becomes a method (7 min)

In Lesson 7 we saw that bag.m is bag.m is False. The reason is that functions are descriptors too. A function has __get__ but no __set__, which makes it a non-data descriptor.

class Bag:
    def weigh(self):
        return 20

b = Bag()
f = Bag.__dict__["weigh"]
print(type(f).__name__, hasattr(f, "__get__"), hasattr(f, "__set__"))
# function True False
m = f.__get__(b, Bag)
print(type(m).__name__, m.__self__ is b, m())
# method True 20
print(b.weigh is b.weigh, b.weigh == b.weigh, Bag.weigh is f)
# False True True

Every time b.weigh is read, Python calls f.__get__(b, Bag) and builds a new bound method: a small object that bundles the function with b. So is is False and == is True. This bundle is also why b.weigh() can be called without passing self. Read from the class (Bag.weigh), there is no instance to bundle, so the plain function comes out.

staticmethod and classmethod are descriptors whose __get__ changes what gets bundled.

class Bag:
    @staticmethod
    def kg_to_lb(kg):
        return round(kg * 2.2046, 1)
    @classmethod
    def empty(cls):
        return cls()

class Suitcase(Bag):
    pass

print(Bag.kg_to_lb(20), Bag().kg_to_lb(20))
# 44.1 44.1
print(type(Suitcase.empty()).__name__, Suitcase.empty.__self__ is Suitcase)
# Suitcase True
print(type(Bag.__dict__["empty"]).__name__)
# classmethod
What __get__ bundlesFirst argument
Plain functionThe instanceself
staticmethodNothingNone
classmethodThe class (type(obj))cls

classmethod receives the class it was called on, so Suitcase.empty() builds a Suitcase. It is used for "alternative constructors" that build the right spec even under inheritance.

10-4. The order of the checkpoint (9 min)

When reading obj.x, Python (precisely, object.__getattribute__) looks in this order.

OrderWhere it looksExample
1A data descriptor on the class (MRO), one with __set__ or __delete__property, Positive
2The instance __dict__b.label = "ICN"
3A non-data descriptor on the class (only __get__)functions, staticmethod, classmethod
4A plain class attributekind = "bag"
5__getattr__ (if defined)
6AttributeError

Data descriptors come before the instance __dict__, so the checkpoint is called before any value inside the bag. That is why it was fine for Positive in 10-2 to store its value in __dict__ under the same name. Functions, on the other hand, come after __dict__, so using the same name on an instance hides the method.

Pick the attributes one at a time in the flow chart below. It shows at which step the answer comes out.

Checkpoint flow

1

class Positive:

2

def __set_name__(self, owner, name):

3

self.name = name

4

def __get__(self, obj, owner):

5

return obj.__dict__[self.name]

6

def __set__(self, obj, value):

7

obj.__dict__[self.name] = value

8

class Bag:

9

kg = Positive()

10

kind = "bag"

11

def weigh(self):

12

return self.kg

13

def __repr__(self):

14

return "Bag()"

15

b = Bag()

16

b.kg = 20

17

b.label = "ICN"

b.

1

Data descriptor on the class (__get__ + __set__)?

2

In the instance __dict__?

3

Non-data descriptor on the class (only __get__, like a function)?

4

A plain class attribute?

5

Is there __getattr__?

6

AttributeError

Result: b. →

kg is also in __dict__, but the answer comes first from step 1 (data descriptor). label is answered at step 2, weigh at step 3 (a function), kind at step 4, and color is nowhere, so it raises AttributeError.

This time we deliberately put the same names into the instance __dict__. The last line is a "side door" that writes straight into __dict__ to dodge the checkpoint.

Checkpoint flow

1

class Bag:

2

kind = "bag"

3

def weigh(self):

4

return 20

5

@property

6

def tag(self):

7

return "T-" + self.kind

8

def __getattr__(self, name):

9

return f"<no {name}>"

10

b = Bag()

11

b.weigh = "shadowed"

12

b.kind = "suitcase"

13

b.__dict__["tag"] = "fake"

b.

1

Data descriptor on the class (__get__ + __set__)?

2

In the instance __dict__?

3

Non-data descriptor on the class (only __get__, like a function)?

4

A plain class attribute?

5

Is there __getattr__?

6

AttributeError

Result: b. →

For tag, the property (step 1) wins even though __dict__ holds 'fake'. For weigh and kind, __dict__ (step 2) hides the function and the class attribute. color is nowhere, so __getattr__ (step 5) answers.
  • A property without a setter is still a data descriptor. A property always has __set__, and without a setter it raises AttributeError inside it. That is why __dict__ cannot hide it.
  • Writing (b.x = v) has a shorter order. If the class has a descriptor with __set__, Python calls it; otherwise it writes into the instance __dict__.

Pin says

→ To memorize the order in one line: 'data descriptor > instance __dict__ > non-data descriptor like a function > class attribute > __getattr__'.

10-5. __getattr__, __getattribute__, and type (5 min)

These two similarly named handles are called at different times.

  • __getattribute__: called on every attribute read, always. The whole order from 10-4 lives inside object.__getattribute__. Overriding it easily leads to infinite recursion, so it is rarely done.
  • __getattr__: the last rescue, called only when the order above found nothing.
class Loud:
    def __init__(self):
        self.kg = 20
    def __getattribute__(self, name):
        print("getattribute", name)
        return super().__getattribute__(name)
    def __getattr__(self, name):
        print("getattr", name)
        return None

x = Loud()
v = x.kg
# getattribute kg
v = x.color
# getattribute color
# getattr color

Finally, if classes are objects, what is the type of a class? It is type. A class statement is really the same as calling type(name, bases, attribute dict).

class Bag:
    kind = "bag"

print(type(Bag()).__name__, type(Bag), type(type))
# Bag <class 'type'> <class 'type'>
Dyn = type("Dyn", (Bag,), {"kind": "dyn"})
print(Dyn.kind, [c.__name__ for c in Dyn.__mro__])
# dyn ['Dyn', 'Bag', 'object']

Inheriting from type to change how classes themselves are created is called a metaclass. For most jobs, __init_subclass__ (Lesson 9) and descriptors are enough, so for this course just remember the name.

Case closed (10 min)

Move the check into the Positive checkpoint, and creating, changing and decreasing with -= all pass through the same checkpoint.

class Positive:
    def __set_name__(self, owner, name):
        self.name = name
    def __get__(self, obj, owner=None):
        return self if obj is None else obj.__dict__[self.name]
    def __set__(self, obj, value):
        if value < 0:
            raise ValueError(f"{self.name} must be >= 0: {value}")
        obj.__dict__[self.name] = value

class Bag:
    kg = Positive()
    cm = Positive()
    def __init__(self, kg, cm):
        self.kg, self.cm = kg, cm

b = Bag(20, 55)
for change in (lambda: setattr(b, "kg", -1), lambda: Bag(-3, 40)):
    try:
        change()
    except ValueError as e:
        print("blocked:", e)
# blocked: kg must be >= 0: -1
# blocked: kg must be >= 0: -3
b.kg -= 30
# ValueError: kg must be >= 0: -10

Setting it with setattr, creating a new bag, decreasing with -=: all are blocked. b.kg -= 30 is two steps, reading b.kg (__get__) and writing back the result (__set__), so there is no way around the checkpoint.

Oh replays the scale records. No more negative weights come through. The supervisor says, "Better than locking doors is making every route go through the checkpoint."

Closing report — LOST-010. The weight check existed only in init, so the later b.kg -= 30 bypassed it. Solved by moving the check into a data descriptor (Positive) so every write passes through the checkpoint.

Exercises

  1. Change Positive so it takes a lower bound (kg = AtLeast(0), cm = AtLeast(10)). Use the value received in __init__ for the check.
  2. In the code for the first flow chart in 10-4, if you add b.weigh = "x", at which step is b.weigh answered? What about b.kg = "x"?
  3. In a class that defines only __getattr__, use print to check whether __getattr__ is called when reading an attribute set in __init__.

The supervisor's notebook

  • @property puts a check on reading and writing an attribute. Keep the check in the setter. No setter means read-only.

  • A descriptor is a class attribute with __get__/__set__. __set_name__ tells it its name, so it can be reused on many attributes.

  • A function is a non-data descriptor with only __get__. Every read of b.m creates a new bound method.

  • staticmethod bundles nothing; classmethod bundles the class.

  • Read order: data descriptor > instance __dict__ > non-data descriptor > class attribute > __getattr__.

  • __getattribute__ always runs, __getattr__ only when nothing was found. The class of a class is type.

← Previous

Lesson 9 · The spec behind the spec: inheritance and MRO

Next →

Lesson 11 · Unclaimed bags: memory and GC

Get news about new lessons

We'll email you when this course gets new lessons or a follow-up course opens.

We collect: your email address · Purpose: news about new lessons or the launch of this course · Kept until: you ask us to remove it. We never use it for anything else or share it. Reply to any notice email and we'll delete it right away.

Questions & answers

Ask anything about this lesson. Questions from every language version of this page appear together.

Shown as plain text, without links or formatting.

R

ReflexKR

Learn Python and Reflex by doing. Every example was run and verified on Reflex 0.10.0.

ReflexKR is free, with no banner ads. If it helped you, you can support it with a coffee. ☕ Buy us a coffee

Affiliate disclosure
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex