AI EngineeringZero to ProductionHome·About·Contact
Appendix · Python for AI Agents · Part 8

The object model & metaclasses

P3 taught classes; this part explains the machinery underneath them. You will see how attribute access really resolves (type, then instance, then __getattr__), write a descriptor and discover that @property and even plain methods are descriptors, watch the MRO get computed by C3 linearization on a diamond, measure what __slots__ buys you, and finally reach the top of the tower: metaclasses, where a class is itself an instance of type and you can hook class creation. We prefer __init_subclass__ where it suffices and reserve metaclasses for when it does not. Every mechanism is demonstrated with runnable code and honest output.

⏱️ ~2.5 hours🎯 Advanced → Industry🧩 the object modelrunnable CPython 3.13

Learning objectives

  • Use the dunder protocol to make objects work with operators, ordering, hashing and built-ins.
  • Trace attribute lookup through __getattribute__, the class MRO, and the __getattr__ fallback.
  • Write a descriptor and explain why @property and methods are themselves descriptors.
  • Compute an MRO by C3 linearization and explain cooperative super() on a diamond.
  • Apply __slots__ and measure its memory and attribute-restriction effects.
  • Build a class with a metaclass, and choose __init_subclass__ when it suffices.

1 · The dunder protocol

Python's built-in syntax is protocol-based: a + b calls a.__add__(b), len(x) calls x.__len__(), a < b calls a.__lt__(b), and membership in a set/dict uses __hash__ and __eq__. Implement the right dunders and your own class becomes a first-class citizen. @total_ordering fills in the remaining comparisons from __eq__ and one of __lt__/__gt__.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it
dunder.py# Dunders let your objects plug into built-in syntax and protocols.
from functools import total_ordering

@total_ordering
class Money:
    def __init__(self, cents): self.cents = cents
    def __repr__(self): return f"Money({self.cents})"          # repr()
    def __eq__(self, o): return isinstance(o, Money) and self.cents == o.cents
    def __lt__(self, o): return self.cents < o.cents            # + total_ordering => <=,>,>=
    def __add__(self, o): return Money(self.cents + o.cents)    # a + b
    def __hash__(self): return hash(self.cents)                 # usable in set/dict

a, b = Money(150), Money(99)
print(a + b)                 # Money(249)  via __add__
print(a > b, a == Money(150))# True True   via __lt__/@total_ordering and __eq__
print(sorted([a, b]))        # [Money(99), Money(150)]  via __lt__
print(len({a, Money(150)}))  # 1  via __hash__/__eq__ (deduped)
Money(249)
True True
[Money(99), Money(150)]
1

2 · Attribute lookup

Every attribute access obj.name goes through __getattribute__, which searches in a defined order: data descriptors on the type, then the instance __dict__, then non-data descriptors / class attributes along the MRO. Only if all of that fails does Python call __getattr__ — the fallback hook. Overriding __getattribute__ intercepts everything and must delegate via super() or it recurses forever.

Try it
lookup.py# Attribute lookup: __getattribute__ runs for EVERY access; __getattr__ is the
# fallback that runs ONLY when normal lookup fails.
class Config:
    def __init__(self): self.host = "localhost"
    def __getattr__(self, name):
        # only reached when 'name' is not found the normal way
        return f"<default:{name}>"

c = Config()
print(c.host)        # localhost   -> found normally, __getattr__ NOT called
print(c.port)        # <default:port> -> missing, so __getattr__ handles it

# __getattribute__ intercepts everything (use with care -> easy infinite loops)
class Logged:
    def __getattribute__(self, name):
        # MUST delegate via super() to avoid recursing forever
        val = super().__getattribute__(name)
        print(f"  access {name!r}")
        return val
    def __init__(self): self.x = 1

obj = Logged()
_ = obj.x            # prints the access line, then returns 1
localhost
<default:port>
  access 'x'
obj.name access data descriptor? type first instance dict? then instance class / MRO? then classes __getattr__ last resort

3 · Descriptors — how property & methods work

A descriptor is any object that defines __get__ (and optionally __set__/__delete__) and is stored on a class. It intercepts attribute access for every instance. This is not an exotic feature — it is the mechanism behind @property, classmethod, staticmethod, and even ordinary methods: a plain function is a descriptor whose __get__ binds self. Writing one yourself demystifies all of them.

Try it
descriptor.py# A descriptor is any object defining __get__/__set__/__delete__ that lives on a
# CLASS. It controls attribute access for instances. @property and methods are
# descriptors under the hood.
class Positive:
    def __set_name__(self, owner, name):        # remember the attribute's name
        self.store = "_" + name
    def __get__(self, obj, objtype=None):
        if obj is None: return self             # accessed on the class
        return getattr(obj, self.store)
    def __set__(self, obj, value):
        if value < 0:
            raise ValueError(f"{self.store[1:]} must be >= 0")
        setattr(obj, self.store, value)

class Account:
    balance = Positive()                        # a data descriptor on the class
    def __init__(self, b): self.balance = b     # goes through __set__ -> validated

acct = Account(100)
print("balance:", acct.balance)                 # via __get__
try:
    acct.balance = -5                           # via __set__ -> guard trips
except ValueError as e:
    print("rejected:", e)

# Proof that @property and methods ARE descriptors:
class Demo:
    @property
    def p(self): return 1
    def m(self): return 2
print("property is descriptor:", hasattr(Demo.__dict__["p"], "__get__"))
print("function is descriptor:", hasattr(Demo.__dict__["m"], "__get__"))
balance: 100
rejected: balance must be >= 0
property is descriptor: True
function is descriptor: True
Data vs non-data descriptorsA descriptor with __set__/__delete__ is a data descriptor and wins over the instance __dict__; one with only __get__ is non-data and can be shadowed by an instance attribute. That precedence is exactly why a @property (a data descriptor) can't be accidentally overwritten by self.x = ....

4 · The MRO & C3 linearization

With multiple inheritance Python must pick a single order to search bases. It uses C3 linearization, which guarantees a consistent order that (1) keeps each class before its parents and (2) preserves the left-to-right order you wrote. The result is cls.__mro__. super() does not mean “the literal base class” — it means “the next class in the MRO,” which is what makes cooperative multiple inheritance (every __init__ calling super().__init__()) work.

Try it
mro.py# The MRO (method resolution order) is the single, linear order Python searches
# for attributes. It is computed by the C3 linearization algorithm.
class A:
    def who(self): return "A"
class B(A):
    def who(self): return "B"
class C(A):
    def who(self): return "C"
class D(B, C):        # the classic diamond
    pass

print([cls.__name__ for cls in D.__mro__])   # D, B, C, A, object
print(D().who())                             # 'B' -> first match along the MRO

# super() follows the MRO, NOT the literal base — cooperative multiple inheritance
class Base:
    def __init__(self): self.log = ["Base"]
class L(Base):
    def __init__(self): super().__init__(); self.log.append("L")
class R(Base):
    def __init__(self): super().__init__(); self.log.append("R")
class LR(L, R):
    def __init__(self): super().__init__(); self.log.append("LR")

print(LR().log)                              # ['Base', 'R', 'L', 'LR']
['D', 'B', 'C', 'A', 'object']
B
['Base', 'R', 'L', 'LR']
Inconsistent hierarchies are rejectedIf no C3-consistent order exists (e.g. bases ordered (X, Y) in one place and (Y, X) in another, then combined), Python raises TypeError: Cannot create a consistent method resolution order at class-definition time — a real design signal, not a bug to work around.

5 · __slots__ — trading flexibility for footprint

By default each instance owns a __dict__, which is flexible but costs memory and a hash lookup per attribute. Declaring __slots__ tells Python to store a fixed set of attributes in a compact layout instead: no per-instance __dict__, lower memory, slightly faster access — and no adding attributes that aren't declared. It matters when you allocate millions of small objects (graph nodes, records, tokens).

Try it
slots.py# __slots__ replaces the per-instance __dict__ with fixed storage: less memory,
# slightly faster attribute access, and no arbitrary new attributes.
import sys, tracemalloc

class WithDict:
    def __init__(self, a, b, c): self.a, self.b, self.c = a, b, c

class WithSlots:
    __slots__ = ("a", "b", "c")
    def __init__(self, a, b, c): self.a, self.b, self.c = a, b, c

d, s = WithDict(1, 2, 3), WithSlots(1, 2, 3)
print("dict instance has __dict__ :", hasattr(d, "__dict__"))   # True
print("slots instance has __dict__:", hasattr(s, "__dict__"))   # False

try:
    s.d = 4                       # not in __slots__
except AttributeError as e:
    print("slots blocks new attr :", type(e).__name__)

def footprint(cls, n=100_000):
    tracemalloc.start()
    keep = [cls(1, 2, 3) for _ in range(n)]
    used, _ = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return used

md, ms = footprint(WithDict), footprint(WithSlots)
print(f"100k objs -> dict {md/1e6:.1f} MB  slots {ms/1e6:.1f} MB")
dict instance has __dict__ : True
slots instance has __dict__: False
slots blocks new attr : AttributeError
100k objs -> dict 10.4 MB  slots 6.4 MB
The exact MB and ratio are machine-dependentYou will reliably see slots use less memory than dict-based instances and reject undeclared attributes; the precise numbers vary by CPython version and platform. Measure on your target. Note slots interacts with inheritance and @property — add '__dict__' to slots if you need both.

6 · Metaclasses — the type of a type

Here is the keystone: a class is an object, and its type is type. That means type is a metaclass — the thing that builds classes — and you can subclass it to hook class creation. type(name, bases, namespace) is literally what the class statement calls. A custom metaclass runs code at class-definition time: enforce interfaces, auto-register plugins, inject methods.

Try it
metaclass.py# A class is an OBJECT whose type is `type`. type is the default metaclass.
class Plain: pass
print(type(Plain))          # <class 'type'>
print(type(type))           # <class 'type'>  (type is its own type)

# type(name, bases, namespace) builds a class dynamically — same as `class`.
Dyn = type("Dyn", (), {"greet": lambda self: "hi"})
print(Dyn().greet())        # hi

# A real metaclass: auto-register every subclass in a plugin registry.
class PluginMeta(type):
    registry = {}
    def __new__(mcls, name, bases, ns, **kw):
        cls = super().__new__(mcls, name, bases, ns)
        if bases:                                   # skip the base itself
            PluginMeta.registry[name.lower()] = cls
        return cls

class Plugin(metaclass=PluginMeta): pass
class CsvLoader(Plugin): pass
class JsonLoader(Plugin): pass

print(sorted(PluginMeta.registry))                  # ['csvloader', 'jsonloader']
print(type(CsvLoader).__name__)                     # PluginMeta
<class 'type'>
<class 'type'>
hi
['csvloader', 'jsonloader']
PluginMeta

7 · Prefer __init_subclass__ when you can

Most “do something when a subclass is defined” needs — registration, validation, defaulting a class attribute — don't require a metaclass at all. __init_subclass__ is a classmethod-like hook on the parent that runs for each subclass, is far easier to read, and composes cleanly. Reach for a metaclass only when you must control the class object's creation itself (custom __new__/namespace) or the metaclass hierarchy.

Try it
init_subclass.py# __init_subclass__ hooks subclass creation WITHOUT a metaclass — usually the
# right tool. It runs on the PARENT each time a subclass is defined.
class Tool:
    registry = {}
    def __init_subclass__(cls, /, name=None, **kw):
        super().__init_subclass__(**kw)
        cls.tool_name = name or cls.__name__.lower()
        Tool.registry[cls.tool_name] = cls

class Search(Tool, name="search"): pass
class Calc(Tool): pass

print(sorted(Tool.registry))     # ['calc', 'search']
print(Search.tool_name)          # search
['calc', 'search']
search
NeedReach for
Register/validate subclasses, set class attrs__init_subclass__ (simplest)
Customize a per-class descriptor's name__set_name__
Control the class object's construction / namespacea metaclass
Change attribute access on instancesdescriptors / __getattr__

✓ Checkpoint — you can move on when you can…

  • Implement __eq__ + __hash__ + __lt__ and use the object in a set and sorted().
  • Predict when __getattr__ fires vs when normal lookup or a data descriptor wins.
  • Write a descriptor with a validating __set__ and explain why @property is one.
  • Compute the MRO of a diamond and trace a cooperative super().__init__() chain.
  • Explain what __slots__ removes and choose between __init_subclass__ and a metaclass.
✓ Knowledge check

You define __eq__ on a class and suddenly it can no longer be used as a dict key — TypeError: unhashable type. Why, and what is the correct fix?

Show answer
Defining __eq__ makes Python set __hash__ = None automatically, because the invariant “equal objects must have equal hashes” could otherwise be violated. An object with __hash__ = None is unhashable, so it can't be a dict key or set member. Fix: also define __hash__ consistently with __eq__ (hash the same fields you compare) — e.g. def __hash__(self): return hash((self.x, self.y)). If the object is mutable and you want it unhashable on purpose, leave it as is.
✓ Knowledge check

A teammate reaches for a metaclass to auto-register agent tool classes in a registry. Is that the right tool, and what's the lighter alternative?

Show answer
It works, but a metaclass is usually overkill for registration and adds a metaclass to the whole hierarchy (which can conflict with other metaclasses like ABCMeta). The lighter, more readable tool is __init_subclass__ on a base Tool class: it runs once per subclass definition, can read keyword arguments from the class header (class Search(Tool, name="search")), and needs no metaclass. Reserve metaclasses for controlling class construction itself — custom __new__, namespace preparation, or enforcing that the metaclass hierarchy is consistent.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Make a value type printable and comparableBeginner

Context: Domain value objects (money, versions, coordinates) are far nicer when they print readably and compare by value.

Your task: Write a small value class with __repr__ and __eq__ so it prints clearly and compares correctly.

Requirements:

  • __init__ storing two fields
  • __repr__ returning a useful string
  • __eq__ comparing by field values; test equality of two equal instances

💡 Hint: __repr__ should look like a constructor call.

Show solution
Solution
class Point:
    def __init__(self, x, y): self.x, self.y = x, y
    def __repr__(self): return f'Point({self.x}, {self.y})'
    def __eq__(self, o): return isinstance(o, Point) and (self.x, self.y) == (o.x, o.y)

print(Point(1, 2))            # Point(1, 2)
print(Point(1, 2) == Point(1, 2))  # True
print(Point(1, 2) == Point(3, 4))  # False
Point(1, 2)
True
False
Exercise 2 · A defaulting config via __getattr__Intermediate

Context: Config objects are cleaner when missing keys fall back to a default instead of raising.

Your task: Write a Config whose known attributes work normally but whose unknown attributes return a default, using __getattr__.

Requirements:

  • Set one real attribute in __init__
  • __getattr__ returns None (or a marker) for anything missing
  • Show a known attribute and a missing one

💡 Hint: __getattr__ only fires when normal lookup fails.

Show solution
Solution
class Config:
    def __init__(self): self.host = 'localhost'
    def __getattr__(self, name): return None   # default for anything missing

c = Config()
print(c.host)     # localhost  (normal lookup)
print(c.port)     # None       (fell through to __getattr__)
localhost
None
Guard against recursionInside __getattr__, don't access an undefined attribute of self or you'll recurse. Reading declared attributes is fine; unknown ones re-trigger the hook.
Exercise 3 · Write a typed, validating descriptorAdvanced

Context: Reusable field validation (non-negative, in range, correct type) is best expressed once as a descriptor and shared across classes.

Your task: Write a Typed descriptor that enforces an expected type on assignment and reuse it on two fields.

Requirements:

  • __set_name__ to capture the field name
  • __set__ raises TypeError if the value isn't the expected type
  • __get__ returns the stored value; demonstrate a good and a bad assignment

💡 Hint: Store the backing value under a mangled name like '_' + name.

Show solution
Solution
class Typed:
    def __init__(self, expected): self.expected = expected
    def __set_name__(self, owner, name): self.store = '_' + name
    def __get__(self, obj, objtype=None):
        return self if obj is None else getattr(obj, self.store)
    def __set__(self, obj, value):
        if not isinstance(value, self.expected):
            raise TypeError(f'expected {self.expected.__name__}')
        setattr(obj, self.store, value)

class User:
    name = Typed(str)
    age = Typed(int)
    def __init__(self, name, age): self.name, self.age = name, age

u = User('Ada', 36); print(u.name, u.age)   # Ada 36
try: User('Ada', 'old')
except TypeError as e: print('rejected:', e)
Ada 36
rejected: expected int
Exercise 4 · Trace a cooperative super() chainExpert

Context: Cooperative multiple inheritance (mixins that each do part of setup) only works if every class calls super() and you understand the MRO order.

Your task: Build a diamond of classes that each append to a shared list in __init__ via super(), then predict and verify the order from the MRO.

Requirements:

  • A Base and two mixins L, R, and a combined LR
  • Each __init__ calls super().__init__() then appends its name
  • Print LR.__mro__ and the resulting list; confirm they match

💡 Hint: The append order is the reverse of the call order, following the MRO.

Show solution
Solution
class Base:
    def __init__(self): self.log = ['Base']
class L(Base):
    def __init__(self): super().__init__(); self.log.append('L')
class R(Base):
    def __init__(self): super().__init__(); self.log.append('R')
class LR(L, R):
    def __init__(self): super().__init__(); self.log.append('LR')

print([c.__name__ for c in LR.__mro__])  # ['LR','L','R','Base','object']
print(LR().log)                           # ['Base','R','L','LR']
['LR', 'L', 'R', 'Base', 'object']
['Base', 'R', 'L', 'LR']

Calls go down the MRO (LR→L→R→Base); appends happen as the stack unwinds, so the list is the reverse: Base first, LR last.

Exercise 5 · A registry metaclass with validationProfessional

Context: Plugin systems and tool registries often want every subclass registered automatically and required methods enforced at definition time — failing fast, not at first call.

Your task: Write a metaclass that registers each concrete subclass and rejects any that omits a required run method.

Requirements:

  • Metaclass __new__ builds the class, registers it (skip the base), and checks for run
  • A missing run raises TypeError at class creation
  • Show a valid subclass registering and an invalid one failing

💡 Hint: Check 'run' in namespace (or on the built class) before returning it.

Show solution
Solution
class ToolMeta(type):
    registry = {}
    def __new__(mcls, name, bases, ns, **kw):
        cls = super().__new__(mcls, name, bases, ns)
        if bases:                                   # skip the base class
            if 'run' not in ns:
                raise TypeError(f'{name} must define run()')
            ToolMeta.registry[name.lower()] = cls
        return cls

class Tool(metaclass=ToolMeta): pass
class Search(Tool):
    def run(self): return 'searching'
print(sorted(ToolMeta.registry))       # ['search']
try:
    class Broken(Tool): pass           # no run()
except TypeError as e:
    print('rejected:', e)
['search']
rejected: Broken must define run()
Could this be __init_subclass__?Yes — registration + method-presence checks fit __init_subclass__ perfectly and read more simply. Use the metaclass form only if you also need to control the class object's construction.
Exercise 6 · Choose the object-model tool for a real featureIndustry scenario

Context: You own a framework where users subclass Agent to define tools. Product wants: (a) every tool auto-registered by a slug, (b) a timeout field that must be a positive number, (c) instances to be memory-light because a fleet spawns millions. Junior engineers keep reaching for metaclasses for all three.

Your task: Decide the right mechanism for each requirement and justify it; then sketch the minimal code.

Requirements:

  • Map each requirement to the lightest correct object-model feature
  • Justify why a metaclass is not needed for any of them
  • Give a short combined sketch

💡 Hint: Registration → subclass hook; validated field → descriptor; footprint → slots.

Show solution

(a) Auto-registration → __init_subclass__. It runs once per subclass, can read a slug= keyword from the class header, and needs no metaclass — the readable, conflict-free choice.

(b) Positive timeout → a data descriptor. A reusable Positive/Typed descriptor validates on every assignment and can't be shadowed by an instance attribute. @property is the same mechanism if only one class needs it.

(c) Memory-light instances → __slots__. Removing the per-instance __dict__ cuts footprint measurably across millions of objects and blocks accidental attributes.

None of these require controlling class construction, so a metaclass would add complexity and risk metaclass conflicts (e.g. with ABCMeta) for no benefit.

Sketch
class Positive:
    def __set_name__(self, o, n): self.s = '_' + n
    def __get__(self, o, t=None): return self if o is None else getattr(o, self.s)
    def __set__(self, o, v):
        if not (isinstance(v, (int, float)) and v > 0): raise ValueError('timeout>0')
        setattr(o, self.s, v)

class Agent:
    registry = {}
    def __init_subclass__(cls, /, slug=None, **kw):
        super().__init_subclass__(**kw)
        Agent.registry[slug or cls.__name__.lower()] = cls

class Search(Agent, slug='search'):
    __slots__ = ('timeout',)
    timeout = Positive()
    def __init__(self, timeout): self.timeout = timeout
slots + descriptor caveatA class-level data descriptor and a same-named slot collide. Here the validated field lives as the descriptor's backing store; if you slot the field itself, store the value under a different slotted name or add '__dict__' to slots.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in