AI EngineeringZero to ProductionHome·About·What’s new·Contact
Appendix · Advanced AI Engineering · Part 1

Advanced & Dynamic Python Mechanics

The metaprogramming layer of Python — the machinery that frameworks like Pydantic, FastAPI, and LangChain use under the hood, and that you'll reach for when building an agent that registers tools at runtime, loads plugins dynamically, or wraps every tool call with cross-cutting behaviour. This is where Python stops being a scripting language and becomes a substrate you can bend.

⏱️ ~2 hours🎯 Advanced → Expert🪄 metaprogrammingrunnable

Learning objectives

  • Understand Python's object model: everything is an object, attribute lookup is a protocol.
  • Intercept attribute access with __getattr__ to build proxies and lazy loaders.
  • Make objects callable and use descriptors to control attribute behaviour.
  • Write stateful and parameterized decorators for cross-cutting concerns.
  • Know what a metaclass does (and when never to use one).
  • Build a dynamic tool registry and load agent plugins at runtime with importlib.
How this track relates to the othersThe Python appendix (P1–P6) teaches the language; the DSA track (D1–D6) teaches the structures. This AI Engineering track (A1–A8) is the production layer on top: the mechanics, performance, numerical, framework, and MLOps knowledge that turns a working agent into a deployable system. It assumes you're comfortable with P1–P6.

What this track is orientation

Eight parts covering the advanced engineering behind real AI systems: A1 dynamic mechanics → A2 memory & the GIL → A3 async & streaming → A4 numerical & tensors → A5 strings & tokenization → A6 validation & resiliency → A7 vector DBs & frameworks → A8 MLOps. Each is standalone; take them in any order. Everything is grounded in real agent scenarios.

1 · Python's object model — the foundation advanced

Here's a bug that has cost more than one team a bad afternoon. Someone writes a base class with cache = {} at the class level, meaning it as a per-object default. Every instance quietly shares the same dictionary, so a value cached by one request leaks into another. The code looks obviously correct; it fails in the most confusing way possible. The fix isn't a trick — it's understanding where Python actually stores attributes and in what order it looks for them. Get the object model right and a whole category of "how is that even possible" bugs simply stops happening.

The mental model that unlocks everything: in Python, the dot in obj.x is not a memory read — it's a little search algorithm. Picture asking for a book. You first check your own desk (the instance's own storage), and only if it's not there do you walk to the shared library shelf (the class, then its parents). Two objects of the same class each have their own desk but share one library. That's why the cache = {} bug bites — the dictionary sat on the shared shelf, not on each desk, so everyone scribbled in the same copy.

The common mistake is assuming an attribute "belongs to the object" just because you read it through the object. You can read c.debug perfectly happily even when debug lives on the class — Python transparently falls back to the shelf. The gap only shows up when you write: assigning c.debug = True creates a brand-new copy on that one instance's desk, shadowing the shared one, while everyone else still sees the original. Reads look through the whole chain; writes land on the instance. Holding those two rules in your head separately is most of the battle.

Everything in Python is an object with a type, and attribute access (obj.x) is not a primitive — it's a protocol you can hook into. Understanding the lookup order is what makes the rest of this page make sense.

the dot in obj.x runs this search — first hit wins; a write always lands on the instance obj.x read a name 1 · instance __dict__ (own desk) 2 · class + MRO shared shelf 3 · __getattr__ fallback hook miss miss hit → c.name hit → c.debug return the value — first hit wins still miss AttributeError Attribute lookup is a search, not a memory read. A read walks instance → class/MRO → __getattr__ and stops at the first hit; a write such as c.debug = True always lands on the instance — which is exactly why the shared cache = {} default leaks between objects.
🗺️ How to read this diagram

Trace one attribute access left to right. The blue box on the far left is you writing obj.x; everything to its right is the search Python runs to find where x actually lives.

  • The solid arrow into box 1 is the first place Python looks: the instance's own __dict__ (its "desk"). A dashed "miss" arrow means it wasn't there, so the search steps right — to the class and its MRO (the shared "shelf"), then to __getattr__.
  • The downward arrows into the green box are hits: c.name is found on the instance, c.debug on the class. Whichever is found first wins and the value is returned — the search stops there.
  • If every stage misses, the dashed path falls through to the red box — AttributeError. That red terminus is the "no such attribute" you see in a traceback.

In short: reads travel this whole chain until the first hit; writes skip it and land on the instance. Hold those two rules apart and the shared-mutable-default bug stops being mysterious.

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 — the attribute lookup chain
python# obj.x resolves in this order:
#   1. data descriptors on type(obj)        (e.g. @property)
#   2. obj.__dict__['x']                     (instance attributes)
#   3. non-data descriptors / class attrs on type(obj) and its MRO
#   4. type(obj).__getattr__('x')            (fallback hook)
#   -> AttributeError if all fail

class Config:
    debug = False              # class attribute (shared)
    def __init__(self):
        self.name = "agent"     # instance attribute (per-object)

c = Config()
print(c.name)                  # found in c.__dict__
print(c.debug)                 # not in instance -> found on the class
print(type(c).__mro__)         # the lookup path: (Config, object)
▶ How this works

When you write c.name in Python, that dot is not a simple memory read — Python runs a small search to find where name lives. This lab shows the search order and where two different kinds of attribute actually get stored.

  1. The comment block at the top lists the real lookup order Python follows for obj.x. The one to remember: it checks the instance's own storage before falling back to the class. If nothing is found anywhere, you get an AttributeError.
  2. Inside Config, debug is set at the class level, so it is a class attribute — one copy shared by every Config object. self.name is set inside __init__, so it is an instance attribute — a private copy living on that one object.
  3. Every object keeps its own instance attributes in a hidden dictionary called __dict__. So c.name is found in c.__dict__, but c.debug is not there — Python doesn't give up, it looks at the class next and finds it.
  4. type(c).__mro__ prints the Method Resolution Order: the exact chain of classes Python walks when the instance itself doesn't have the attribute. Here it is (Config, object).

What the output means: Three lines: agent (from the instance), False (found on the class), and (<class 'Config'>, <class 'object'>) — the search path itself.

Try this: Add c.debug = True after creating c, then print c.debug and Config.debug. You'll see the instance now has its own debug that shadows the shared class one — proof that the instance is checked first.

2 · __getattr__ — proxies, lazy loaders, config objects advanced

You've almost certainly used __getattr__ without knowing it. Every time you write settings.database.host against a config library, or an SDK client that "connects on first use," or a mock in a test that answers to any method name you throw at it — something is intercepting an attribute that was never actually defined and conjuring it on the spot. That something is usually __getattr__. It's one of the highest-leverage hooks in the language: a handful of lines turns a plain object into a proxy, a lazy loader, or a friendly view over a nested dict.

The intuition to hold onto is "the fallback that only fires when the normal search comes up empty." Think of a receptionist who only picks up the phone when nobody at the requested desk answers. If the attribute genuinely exists on the instance or its class, Python hands it back directly and __getattr__ never runs. It's summoned only for the misses — which is exactly what makes it safe and cheap: real attributes stay fast, and you get a single place to decide what a missing name should mean (fetch it from a dict, build a remote proxy, or raise).

Here's the mistake that trips everyone, and it's worth stating before you write a line: do not confuse __getattr__ with __getattribute__. The near-identical name hides a huge difference. __getattribute__ intercepts every access, including the ones you make inside your own hook — so a naïve return self._data[name] re-triggers the interceptor on self._data, which re-triggers it, and you get infinite recursion and a blown stack. __getattr__ sidesteps the whole trap because it only runs on misses; reaching for your backing store (a real attribute) doesn't re-enter it. Reach for __getattr__ in 99% of cases and leave __getattribute__ for the rare occasion you truly must see every read.

__getattr__ is called only when normal lookup fails. It's the hook behind config objects that read from a dict, lazy-loading heavy resources, and proxy objects that forward to a remote service. (Its cousin __getattribute__ intercepts every access — powerful but easy to make infinitely recursive; rarely needed.)

__getattr__ is the receptionist — it only picks up when nobody at the desk answers obj.x read a name normal lookup instance + class found → return value hit __getattr__(x) the fallback hook miss fabricate → value raise AttributeError __getattr__ runs only on a miss. If the name really exists, normal lookup returns it and the hook never fires; only when lookup comes up empty does __getattr__ get its turn — to conjure a value or raise AttributeError.
🗺️ How to read this diagram

Start at the blue box on the left (obj.x) and follow the arrows right; the two rows below are the two possible endings.

  • The solid arrow goes to normal lookup (instance then class). If the attribute really exists, that path hits and drops into the green box — __getattr__ is never called at all.
  • The dashed "miss" arrow only fires when normal lookup finds nothing. It steps right to the purple __getattr__ box — the fallback that gets summoned only for the misses.
  • From the hook, two endings: the green box (you fabricate a value — e.g. read it from a backing dict) or the red box (you raise AttributeError, the correct "no such attribute").

In short: real attributes stay fast on the normal path; __getattr__ is the cheap, single place you decide what a missing name should mean.

Try it — a dotted-access config & a lazy client
pythonclass DotConfig:
    def __init__(self, data):
        self._data = data
    def __getattr__(self, key):        # only for missing attrs
        try:
            v = self._data[key]
        except KeyError:
            raise AttributeError(key) from None
        return DotConfig(v) if isinstance(v, dict) else v

cfg = DotConfig({"model": "claude-opus-4-8", "limits": {"rpm": 50}})
print(cfg.model)               # 'claude-opus-4-8'
print(cfg.limits.rpm)          # 50 — nested access "just works"

class LazyClient:
    def __init__(self):
        self._real = None
    def __getattr__(self, name):
        if self._real is None:
            print("... connecting on first use")
            self._real = {"messages": lambda: "resp"}   # stand-in for a heavy SDK client
        return self._real[name]
▶ How this works

__getattr__ is a hook Python calls only when normal attribute lookup fails — i.e. the name was not found on the instance or its class. That single fact lets you fake attributes that don't really exist, which is how dotted-config objects and lazy clients work.

  1. DotConfig stores a plain dictionary in self._data. When you ask for cfg.model and there is no real model attribute, Python calls __getattr__(self, "model"), which looks the key up in _data and returns it.
  2. If the key is missing it raises AttributeError — the correct error for "no such attribute". The from None just hides the internal KeyError so the message stays clean.
  3. The clever line is return DotConfig(v) if isinstance(v, dict) else v: if a value is itself a dict, it gets wrapped in another DotConfig. That is why cfg.limits.rpm chains with dots all the way down.
  4. LazyClient uses the same hook to defer expensive work. The real client is None until the first attribute access; on that first call it prints, builds the real object, and from then on just forwards to it.

What the output means: cfg.model gives 'claude-opus-4-8' and cfg.limits.rpm gives 50 — even though neither attribute was ever explicitly defined on the class.

Try this: Ask for cfg.missing. Because the key isn't in _data, __getattr__ raises AttributeError — exactly what a real missing attribute would do.

🔗 Used in the courseA lazy client defers creating the Anthropic SDK object (and reading the key) until the first real call — handy in tests that never hit the API (P5). Dotted config objects are how settings libraries expose nested YAML/env config cleanly.

3 · __call__ — objects that behave like functions intermediate → advanced

Picture the moment your "simple function" needs to remember something. You wrote a rate limiter as a plain function; now it needs to track when it last fired. You wrote a tool call as a function; now it needs to count invocations for the audit log. The lazy reach is a global variable — and globals are how a codebase slowly rots: two callers share state they didn't know they shared, tests can't isolate, and nobody can tell what the function actually depends on. __call__ is the clean escape: keep the ergonomics of "just call it with parentheses," but give it a private memory that travels with the object.

The everyday analogy is a vending machine versus a hand-crank. A bare function is a hand-crank: you turn it, it does its one thing, it forgets you the instant it's done. A callable object is a vending machine — you still just press a button (the parentheses), but behind the panel it's tracking stock, counting sales, holding your configuration. Same interface for the caller; a real interior with state for the author. The __call__ method is the button: define it, and Python lets you write obj(...) even though obj is not a function.

Why prefer this over a closure, which can also carry state? Two reasons that matter in real systems. First, introspection: an object can expose its state as ordinary attributes (tool.calls, limiter.remaining) that you can read, log, and assert on in tests — a closure hides its captured variables where you can't reach them. Second, configurability: an object naturally grows methods (reset(), describe()) and can be re-parameterized without rebuilding it. The common mistake is over-reaching the other way — using a heavyweight class where a two-line closure would do. The rule of thumb: if the "function" needs inspectable state, extra methods, or a name in an error message, make it a callable object; otherwise a closure is fine.

Implement __call__ and an instance becomes callable like a function — but with state. This is the clean way to build configurable, stateful "functions": a rate limiter, a retry policy, a tool with bound config.

bare function — the hand-crank tool(x) runs, returns, forgets no memory of the last call callable object — the vending machine tool(x) same parentheses as the left __call__ runs self.calls += 1 · reads self.fn state lives on the instance Same call syntax, a real interior. A plain function forgets you the instant it returns; an object with __call__ is invoked with the identical tool(x) parentheses but carries private state (a call counter, bound config) that survives between calls.
🗺️ How to read this diagram

Compare the two sides left and right — the dashed line splits them. Both are invoked the same way from the caller's seat; the difference is what happens after.

  • The left panel is a bare function (the hand-crank): you call tool(x), it runs and returns, and the red box says it forgets — nothing survives to the next call.
  • The right panel is a callable object (the vending machine): the top box shows the caller writes the same tool(x) parentheses, so nothing changes for them.
  • The green box is the payoff: __call__ runs, bumps self.calls and reads self.fn — state that lives on the instance and persists across calls, which the bare function on the left can't do without a global.

In short: identical call ergonomics, but the object has a memory. Reach for __call__ when a "function" needs inspectable state or extra methods.

Try it — a stateful callable tool
pythonclass Tool:
    def __init__(self, name, fn, calls=0):
        self.name, self.fn, self.calls = name, fn, calls
    def __call__(self, **kwargs):     # the instance is callable
        self.calls += 1               # ... and remembers state across calls
        return self.fn(**kwargs)

scale = Tool("scale", lambda replicas: f"scaled to {replicas}")
print(scale(replicas=3))         # 'scaled to 3'
print(scale.calls)              # 1 — state persists, unlike a bare function
▶ How this works

Normally only functions can be "called" with parentheses. Defining a __call__ method makes an instance itself callable — you can write scale(...) even though scale is an object, not a function. The payoff is that the object can remember things between calls.

  1. Tool.__init__ stores a name, a function fn, and a counter calls that starts at 0 — ordinary per-instance state.
  2. __call__(self, **kwargs) is the special method that runs when you put parentheses after the object. It bumps self.calls by one, then hands the arguments to the stored fn and returns its result.
  3. scale = Tool("scale", lambda replicas: ...) builds one such object; the lambda is just a tiny inline function used as the tool's body.
  4. scale(replicas=3) looks like a function call but actually triggers __call__. Afterwards scale.calls is 1 — the count survived because it lives on the object.

What the output means: First line prints scaled to 3; second prints 1, the number of times the tool has been called.

Try this: Call scale(replicas=5) a second time, then print scale.calls — it reads 2. A plain function couldn't keep that running count without a global variable.

4 · Descriptors — the magic behind @property, ORM fields, Pydantic expert intermediate

Imagine you have ten config fields that all need the same guard. temperature, top_p, and half a dozen others must each sit between 0 and 1. The copy-paste answer is ten @property getters and ten setters that all say the same "is it in range?" thing — a hundred lines of near-identical validation that someone will eventually update in nine places out of ten and introduce a silent bug. Descriptors exist precisely to kill that duplication: write the "bounded field" logic once, then declare temperature = Bounded(0, 1) as a one-liner on each field. This isn't an academic curiosity; it's the single idea that makes Pydantic, SQLAlchemy, and Django's field systems possible.

The analogy that makes it click: a descriptor is a reusable smart doorbell you install on a field. A plain attribute is a doorway anyone walks through unchecked. A descriptor puts a small device on that doorway — __get__ fires whenever someone reads the field, __set__ whenever someone writes it — and because the device is its own object, you can hang the identical device on as many doorways (fields, classes) as you like. @property is the same idea but welded to one specific doorway; a descriptor is the device sold separately, so it's shareable.

The mechanic that surprises people, and the mistake to avoid: a descriptor is stored on the class, once, yet it must hold a different value for each instance. If you naïvely stash the value on self (the descriptor object) instead of on the instance being written to, all your objects share one value — the same shared-state trap from §1, now hiding inside your abstraction. The disciplined fix is __set_name__, which Python calls at class-definition time to tell the descriptor which attribute name it was bound to; the descriptor then keeps each instance's value on that instance under a private key. Once you see __get__/__set__/__set_name__ working together, @property, classmethod, ORM columns, and Pydantic fields all stop being magic and become the same pattern wearing different hats.

A descriptor is an object that defines __get__/__set__; when it's a class attribute, those methods intercept access to it on instances. This is the mechanism behind @property, classmethod, ORM columns, and Pydantic/dataclass fields. Build one and you understand all of them.

one Bounded(0,1) on the class guards the field on every instance write cfg.temp = 2.0 an assignment descriptor __set__ validates lo ≤ v ≤ hi ? in range → store on obj out → ValueError read cfg.temp an access descriptor __get__ fetches value from _temp stored per instance Every read and write goes through the descriptor. Declared once on the class, it intercepts assignment (__set__ validates the range, then stores per-instance) and access (__get__ fetches it back) — the exact machinery under @property, ORM columns, and Pydantic fields.
🗺️ How to read this diagram

Two rows, both flowing left → right: the top row is a write, the bottom row is a read. Both pass through the same descriptor object in the middle.

  • Top row (write): assigning cfg.temp = 2.0 doesn't store directly — it's routed into the descriptor's __set__, which checks lo ≤ v ≤ hi. A pass (solid arrow) stores the value on the instance's private _temp slot; a fail (dashed arrow) raises ValueError.
  • Bottom row (read): reading cfg.temp is routed into __get__, which fetches the value back from that same per-instance _temp slot.
  • The key payoff: the descriptor is declared once on the class yet each instance keeps its own value — that's why validation is written one time but enforced everywhere.

In short: the descriptor is a reusable smart doorbell on a field — __set__ guards writes, __get__ serves reads, and per-instance storage keeps objects from clashing.

Try it — a validating, typed field descriptor
pythonclass Bounded:
    """A reusable field that validates its range on every assignment."""
    def __init__(self, lo, hi):
        self.lo, self.hi = lo, hi
    def __set_name__(self, owner, name):   # Python tells us our attribute name
        self.attr = "_" + name
    def __get__(self, obj, objtype=None):
        if obj is None: return self
        return getattr(obj, self.attr)
    def __set__(self, obj, value):
        if not (self.lo <= value <= self.hi):
            raise ValueError(f"{value} not in [{self.lo}, {self.hi}]")
        setattr(obj, self.attr, value)

class GenConfig:
    temperature = Bounded(0.0, 1.0)      # the descriptor guards this field
    top_p = Bounded(0.0, 1.0)
    def __init__(self, temperature, top_p):
        self.temperature = temperature       # -> validated via __set__
        self.top_p = top_p

GenConfig(0.7, 0.9)                    # ok
# GenConfig(2.0, 0.9)  -> ValueError: 2.0 not in [0.0, 1.0]
▶ How this works

A descriptor is an object that controls what happens when you read or write a particular attribute. Put one as a class attribute and its __get__/__set__ methods run every time that field is accessed — this is the exact machinery behind @property, ORM columns, and Pydantic fields.

  1. Bounded holds a low and high limit. __set_name__(self, owner, name) is called automatically when the class is defined; Python tells the descriptor the attribute name it was assigned to, and it saves a private storage key like _temperature.
  2. __get__ runs on reads. It fetches the real value from the private slot with getattr(obj, self.attr). (The if obj is None guard handles access on the class itself rather than an instance.)
  3. __set__ runs on writes and is where validation happens: if the value is outside [lo, hi] it raises ValueError; otherwise it stores it with setattr(obj, self.attr, value).
  4. In GenConfig, temperature = Bounded(0.0, 1.0) attaches a descriptor to the field. So the ordinary-looking line self.temperature = temperature in __init__ secretly runs __set__ and gets validated.

What the output means: GenConfig(0.7, 0.9) succeeds silently. The commented-out GenConfig(2.0, 0.9) would raise ValueError: 2.0 not in [0.0, 1.0] because 2.0 fails the range check in __set__.

Try this: Uncomment (or type) GenConfig(2.0, 0.9) and run it. The error proves the descriptor guards the field on every assignment, not just at construction.

You've been using descriptors all along@property is a descriptor. A Pydantic model field is (conceptually) a descriptor that validates and coerces. A Django/SQLAlchemy column is a descriptor mapping to a DB field. Once you see the __get__/__set__ pattern, these frameworks stop being magic.

5 · Stateful & parameterized decorators advanced

Every real agent tool eventually sprouts the same three chores: retry it on a flaky network, time it for the dashboard, log it for the audit trail. If you paste those chores into each tool, you now maintain the retry logic in twenty places and the day the backoff policy changes you'll miss one. Decorators are how you write the chore once and wrap it around any function — the classic "cross-cutting concern" that has nothing to do with the tool's real job but has to happen anyway. This section is the production form: decorators that take arguments (@retry(times=4)) and can carry state across calls.

Think of a decorator as gift-wrapping a function. The present inside is unchanged; the wrapper adds a layer that runs before and after. A plain decorator is a fixed wrapping paper. A parameterized decorator is a wrapping service you tell "use the red paper, add a bow" — so it needs one extra step: first you hand over your preferences, then it wraps your present. That extra step is the whole reason the code has one more layer of nesting than beginners expect, and it's exactly where people get lost.

Here is the mistake to inoculate yourself against now, because it costs everyone an hour the first time: a decorator with arguments needs three nested functions, not two — arguments → function → wrapper. Layer 1 exists only because you wrote @retry(...) with parentheses; it swallows the options and hands back the real decorator. Skip it and Python passes your function where you expected times, producing a baffling error. The other near-universal slip is forgetting @functools.wraps(fn): without it the wrapper impersonates the original so completely that the function's __name__ and docstring vanish, breaking logs, tracebacks, and anything that introspects it. Memorize the three-layer shape and always wrap with functools.wraps, and stateful decorators become routine.

P4/P5 introduced decorators; here's the production form — decorators that take arguments and carry state, for cross-cutting concerns like timing, retry, and audit logging that you want to wrap around every tool call. The three-layer nesting (args → function → wrapper) is the pattern to memorize.

@retry(times=4) — three layers, each returns the next Layer 1 · retry(times, exc) takes the ARGUMENTS returns → the decorator Layer 2 · decorator(fn) takes the FUNCTION returns → the wrapper Layer 3 · wrapper(*a, **kw) runs the retry loop AT CALL TIME ← because you wrote @retry(...) with parentheses ← receives call_api ← runs when you call_api() Arguments → function → wrapper. A plain decorator has two layers; adding () arguments inserts a third outermost layer whose only job is to swallow the options and hand back the real decorator. Miss it and Python passes your function where times was expected.
🗺️ How to read this diagram

Read the three stacked boxes top to bottom — that's the order they run, and each one hands its result to the box below via the blue arrow.

  • Layer 1 (blue) exists only because you wrote @retry(times=4) with parentheses. It catches the arguments and returns the actual decorator — the note on the right marks this as the step beginners forget.
  • Layer 2 (purple) is the ordinary decorator: it receives the function being wrapped (call_api) and returns its replacement.
  • Layer 3 (amber) is the wrapper — the code that actually runs each time you call call_api(). This is where the retry loop lives.

In short: two layers for a bare decorator, three the moment it takes arguments. The extra top box is the whole reason parameterized decorators look "one level too deep."

Try it — a parameterized retry decorator
pythonimport functools, time

def retry(times=3, exceptions=(Exception,)):   # layer 1: the arguments
    def decorator(fn):                          # layer 2: the function
        @functools.wraps(fn)                     # preserve name/docstring
        def wrapper(*args, **kwargs):            # layer 3: the call
            last = None
            for attempt in range(times):
                try:
                    return fn(*args, **kwargs)
                except exceptions as e:
                    last = e
                    time.sleep(2 ** attempt)        # exponential backoff
            raise last
        return wrapper
    return decorator

@retry(times=4, exceptions=(ConnectionError,))
def call_api():
    ...                                        # retried up to 4x on ConnectionError
▶ How this works

A decorator wraps a function to add behaviour around it. A parameterized decorator — one you call with arguments like @retry(times=4) — needs three nested layers. Getting why there are three is the whole lesson here.

  1. Layer 1 retry(times=3, exceptions=...) takes the decorator's arguments and returns the actual decorator. This layer exists only because we wrote @retry(...) with parentheses.
  2. Layer 2 decorator(fn) receives the function being decorated (here call_api) and returns its replacement.
  3. Layer 3 wrapper(*args, **kwargs) is what actually runs when you call the function. It loops up to times, returns on success, and on a matching exception sleeps with time.sleep(2 ** attempt) (waits 1s, then 2s, then 4s…) before retrying.
  4. @functools.wraps(fn) copies the original function's name and docstring onto the wrapper so it doesn't lose its identity. If every retry fails, raise last re-raises the final error.

What the output means: Nothing prints on its own — this defines the tool. Applied to call_api, it means call_api() will be attempted up to 4 times, retrying only on ConnectionError.

Try this: Change times=4 to times=1: now there is no retry at all — the first failure propagates immediately. The argument you pass in Layer 1 controls Layer 3's loop.

🔗 Used in the courseThis is exactly how production retry/backoff wrappers are built (fuller version in A6 and P6). Timing/audit decorators wrap every DevOps-agent tool call to produce the audit log in Lab 8c.

6 · Metaclasses — the class of a class expert (use sparingly) intermediate

You'll meet metaclasses first as a stack trace you don't understand. You subclass a framework base — a Pydantic v1 model, a Django model, an ORM table — and something happens the instant the class is defined, before you ever create an object: fields get collected, a subclass gets registered, validation gets wired up. That "how did that run when I only defined the class?" magic is a metaclass at work. The honest goal of this section is not to make you write one — it's to make that stack trace stop being scary, so you can read framework code with your eyes open.

The clean way to think about it: a class is a factory for objects; a metaclass is a factory for classes. Just as Config() runs Config.__init__ to build an instance, writing class Config: ... runs a metaclass to build the class object itself. So a metaclass is "the class of a class" — the thing that gets a chance to inspect and tweak a class at the exact moment it's created. That definition-time hook is genuinely useful (auto-registering plugins, injecting methods, validating that subclasses declare required fields) — which is why frameworks reach for it.

Now the part that saves you from yourself, because this is the classic over-engineering trap: you almost certainly should not write a metaclass. As Tim Peters put it, they're "deeper magic than 99% of users should ever worry about — if you wonder whether you need them, you don't." Nearly every legitimate use has a simpler tool that does the same job with a fraction of the cognitive cost: __init_subclass__ for "do something when a subclass is defined" (the lab below), a class decorator for "transform a class after it's built," or a descriptor for "control one field." Metaclasses also compose badly — combine two libraries that each use one and you can get a metaclass conflict that's miserable to resolve. So the skill this section builds is asymmetric on purpose: recognize metaclasses fluently, write them almost never.

A metaclass customizes class creation — it's to a class what a class is to an instance. Frameworks use them to auto-register subclasses or inject behaviour at definition time. You almost never need one (__init_subclass__ or class decorators usually suffice), but you should recognize the pattern when reading framework code.

a class builds objects · a metaclass builds classes — one level up metaclass factory for classes class Config factory for objects a Config() an instance builds builds prefer __init_subclass__ /class decorator / descriptor same definition-time hook, far less magic A metaclass is one level up. Just as class Config is the factory that Config() runs to build an instance, the metaclass is the factory that class Config: ... runs to build the class object — the definition-time hook frameworks exploit, but one you should almost always replace with the simpler tools below.
🗺️ How to read this diagram

Read the top row left → right — each box is a factory for the box to its right, so you're climbing down one level of abstraction with each arrow.

  • The rightmost box is an ordinary instance — Config(). The middle box, class Config, is the factory that built it: calling the class runs it to produce an object.
  • The leftmost purple box is the metaclass — the factory that built the class itself the moment you wrote class Config: .... That's the "how did code run at class-definition time?" magic in framework stack traces.
  • The green box below is the practical verdict: the same definition-time hook is available from __init_subclass__, a class decorator, or a descriptor — so you should recognize metaclasses but almost never write one.

In short: class : instance :: metaclass : class. It's a real hook, but the dashed arrow points to the simpler tools you should reach for instead.

Try it — auto-registering subclasses (the modern, metaclass-free way)
python# __init_subclass__ is the lightweight alternative to a metaclass:
class ToolBase:
    registry = {}
    def __init_subclass__(cls, name, **kw):   # runs when a subclass is DEFINED
        super().__init_subclass__(**kw)
        ToolBase.registry[name] = cls          # auto-register by name

class ScaleTool(ToolBase, name="scale"):    # no manual registry.append!
    def run(self): return "scaled"

class RestartTool(ToolBase, name="restart"):
    def run(self): return "restarted"

print(ToolBase.registry)        # {'scale': ScaleTool, 'restart': RestartTool}
▶ How this works

Frameworks often want every subclass to register itself automatically the moment it is defined — no manual list to keep in sync. The heavy tool for this is a metaclass, but __init_subclass__ is the modern, far simpler way to do the same thing.

  1. ToolBase keeps a shared dictionary registry = {} at the class level.
  2. __init_subclass__(cls, name, **kw) is a special hook that runs once, when a subclass is defined (not when instances are created). cls is the new subclass being created.
  3. Each time a subclass appears, the hook does ToolBase.registry[name] = cls — storing the class under the name passed in the class header. That's the automatic registration.
  4. class ScaleTool(ToolBase, name="scale") passes name="scale" right in the class definition line; that value arrives as the name argument of __init_subclass__. No .append() call anywhere.

What the output means: {'scale': ScaleTool, 'restart': RestartTool} — both tools registered themselves simply by being defined.

Try this: Add class DrainTool(ToolBase, name="drain"): ... and print the registry again. It now includes 'drain' — you never touched the registry code, the class wired itself in.

The rule on metaclasses"Metaclasses are deeper magic than 99% of users should ever worry about. If you wonder whether you need them, you don't." — Tim Peters. Prefer __init_subclass__, class decorators, or descriptors. Pydantic v1 used a metaclass; most code you write should not.

7 · A dynamic tool registry advanced

This is where the last six sections stop being party tricks and become an agent. An LLM that "uses tools" is, underneath the hype, doing something almost boringly concrete: the model emits a name and some arguments — {"tool": "get_pods", "namespace": "prod"} — and your code has to find the right Python function for that name and run it. The naïve version is a growing if name == "get_pods": ... elif name == "restart": ... ladder that everyone has to edit every time a tool is added, and that quietly becomes the merge-conflict capital of the codebase. A registry replaces the ladder with a lookup table, and a one-line decorator replaces the manual bookkeeping.

The mental picture is a hotel front desk with a wall of key slots. Each tool, when it's defined, hangs its own key on the wall labelled with its name (@tools.register("get_pods") — the decorator does the hanging). When a request arrives, the desk clerk (dispatch) reads the name, grabs the matching key, and hands it over — one motion, no walking the whole wall. That "grab by name" is a dictionary lookup, which from D3 you know is O(1): it costs the same whether you have five tools or five hundred, and that flat cost is exactly why real frameworks are built this way.

Two design choices in the lab are worth naming because they're the ones that separate a toy from a tool. First, the decorator returns the function unchanged after registering it — so get_pods is still an ordinary function you can call and unit-test directly; registration is a side effect, not a mutation. Second, dispatch guards the lookup and raises a clear KeyError for an unknown name instead of exploding deep inside a bare self._tools[name] — a non-negotiable, because the name is coming from an LLM that will eventually hallucinate a tool that doesn't exist. Skip the guard and that hallucination becomes a cryptic traceback in production; keep it and it becomes a message you can hand straight back to the model.

Tying it together: a real agent needs to register tools by name and dispatch to them — ideally with a one-line decorator per tool. This is the pattern behind every tool-calling framework.

register once (import time) @register("get_pods") @register("restart") @register("scale") _tools { } "get_pods" → fn "restart" → fn "scale" → fn the "wall of key slots" dispatch(name) O(1) lookup by name LLM emits {"tool":"restart", "namespace":"prod"} name in? found → run the tool unknown → KeyError A lookup table instead of an if/elif ladder. Each tool hangs itself on the _tools dict by name via the decorator; when the model emits a name, dispatch grabs the matching function in O(1) — or raises a clean KeyError the moment the LLM hallucinates a tool that doesn't exist.
🗺️ How to read this diagram

Follow two separate flows into the middle box. On the left is setup (happens once); on the right is a request (happens on every model turn). The box in the centre — the _tools dictionary — is what both flows meet at.

  • Left, solid arrows: each @register("…") decorator hangs its function on the dictionary under its name as the module is imported. This is the "wall of key slots" filling up — no manual list to maintain.
  • Right, amber box: the LLM emits a tool name and arguments. dispatch takes that name and does a single dictionary lookup — O(1), the same cost whether there are five tools or five hundred.
  • The two outcomes: the green box is a hit — the matching function runs. The red box is the guard: an unknown name (a hallucinated tool) raises a clean KeyError instead of crashing deep inside the lookup.

In short: registration fills the table by name; dispatch reads it in constant time and fails loudly on a bad name. That guard is non-negotiable because the name comes from a model that will eventually invent one.

Try it — decorator-based registration + dispatch
pythonclass Registry:
    def __init__(self):
        self._tools = {}
    def register(self, name):           # decorator factory
        def deco(fn):
            self._tools[name] = fn        # side effect: register at import time
            return fn                      # return fn unchanged
        return deco
    def dispatch(self, name, **kwargs):   # O(1) hash-map lookup (D3)
        if name not in self._tools:
            raise KeyError(f"unknown tool: {name}")
        return self._tools[name](**kwargs)

tools = Registry()

@tools.register("get_pods")
def get_pods(namespace):
    return [f"{namespace}/web-1"]

print(tools.dispatch("get_pods", namespace="prod"))   # ['prod/web-1']
▶ How this works

This is the pattern behind real tool-calling agents: register functions by name with a one-line decorator, then look one up and call it by that name. It combines the decorator idea (§5) with a plain dictionary for fast lookup.

  1. Registry.__init__ creates an empty dict self._tools that will map name → function.
  2. register(self, name) is a decorator factory: you call it with a name, and it returns the real decorator deco. Inside deco, the function is stored under that name and then returned unchanged — so the original function still works normally.
  3. dispatch(self, name, **kwargs) is the lookup: it checks the name exists (raising KeyError if not), then calls the stored function with whatever keyword arguments you pass. Dictionary lookup is O(1) — instant regardless of how many tools you have.
  4. @tools.register("get_pods") above def get_pods(...) runs at import time and quietly adds the function to the registry, so it is ready to dispatch later.

What the output means: ['prod/web-1'] — dispatch found get_pods by name and called it with namespace="prod".

Try this: Call tools.dispatch("nope"). You get KeyError: unknown tool: nope — the guard clause in dispatch turning a missing name into a clear error.

🔗 Used in the courseThis is the grown-up version of DISPATCH = {"name": fn} from Ch 4 and the tool registry in Lab 8b — decorator registration keeps tool definition and dispatch declarative and co-located.

8 · Loading agent plugins at runtime expert advanced

Here's the scenario the whole capstone is built around. Your agent ships to a new company, and their ops team needs a tool you've never heard of — a wrapper around their internal deploy script. If adding that tool means editing your core source, cutting a release, and redeploying, the agent isn't really extensible; it's just a program with a plugin-shaped comment. The goal is the opposite: an operator drops their_deploy.py into a plugins/ folder, restarts the agent, and the new tool is simply there — no fork, no rebuild, no touching your code. That's the difference between software you sell and software people extend themselves.

The trick rests on a fact about Python that's easy to overlook: importing a module runs it, top to bottom. An import isn't a passive "load the definitions" — it executes every top-level statement, decorators included. So if a plugin file contains @tools.register("their_deploy"), the mere act of importing that file runs the registration and the tool appears on §7's front-desk wall, with zero wiring on your side. Think of it like a self-addressed, stamped envelope: the plugin already knows how to file itself; you just have to deliver it to the post office. importlib is that delivery mechanism — it imports a module chosen at runtime by name, rather than baked into a static import at the top of your file — and pkgutil.iter_modules is how you find every envelope in the folder to deliver in the first place.

Say the quiet part out loud, though, because "load whatever's in the folder" is also how you get owned: discovery is not the same as trust. Importing a plugin executes arbitrary code with your process's full privileges — so a real loader discovers explicitly (walk a known plugins package you control) rather than importing anything on the path, and treats the plugins directory as a security boundary, not a free-for-all. The common mistake here isn't technical, it's a category error: auto-registration removes the tedious wiring, it does not remove the need to decide whose code you're willing to run. Get that boundary right and the pattern below — registry plus importlib discovery plus __init_subclass__ — is the complete recipe for an agent that grows new abilities without a redeploy.

For an agent that "onboards into any company" (the capstone), tools shouldn't be hard-coded — they should be discoverable plugins the operator drops into a folder. importlib loads modules by name/path at runtime; combined with the registry, tools register themselves simply by being imported.

plugins/ folder their_deploy.py backup.py notify.py iter_modules discover each file import_module runs file top→bottom @register runs tool self-adds _tools registry tool now callable populate Import is the wiring. pkgutil.iter_modules discovers each file in the plugins folder; importlib.import_module imports it, which runs it top-to-bottom — so its @register decorator executes and the tool lands in the registry with zero changes to your core code.
🗺️ How to read this diagram

Follow the flow from the folder on the left, right and then down into the registry — it traces one operator-dropped file becoming a callable tool.

  • Left: the plugins/ folder holds files an operator dropped in. iter_modules is the discovery step — it finds every file without you naming them in advance.
  • Middle: import_module imports each discovered file. The key fact is in the sublabel — importing runs the file top to bottom, it isn't a passive load.
  • Right, amber → green: because the file runs, its @register decorator executes and the tool self-adds to the _tools registry (the §7 wall) — now callable, with no edit to your core code.

In short: discover the folder, import each file, and the act of importing self-registers every tool. Discovery is not trust, though — walk a plugins package you control, don't import arbitrary code.

Try it — discover and import a plugins folder
pythonimport importlib, pkgutil

def load_plugins(package_name="agent_plugins"):
    package = importlib.import_module(package_name)
    for _, mod_name, _ in pkgutil.iter_modules(package.__path__):
        importlib.import_module(f"{package_name}.{mod_name}")   # import = run @register
    # each imported module's @tools.register(...) has now populated the registry

# importlib also enables hot-reload during development:
#   import importlib; importlib.reload(some_module)
▶ How this works

The final piece: instead of hard-coding tools, let an operator drop new tool files into a folder and have the agent discover them at runtime. importlib imports modules by name, and importing a module runs its top-level code — including its @register decorators.

  1. importlib.import_module(package_name) imports the plugins package (a folder of Python files) so we can inspect it.
  2. pkgutil.iter_modules(package.__path__) walks that folder and yields the name of every module inside it — this is the "discovery" step.
  3. For each one, importlib.import_module(f"{package_name}.{mod_name}") imports it. That act of importing runs the module's code, so any @tools.register(...) lines inside execute and populate the registry — no manual wiring.
  4. The trailing comment notes importlib.reload(...), which re-imports a module you've edited — handy for hot-reloading plugins while developing.

What the output means: No visible output by itself. The effect is a side effect: after load_plugins() runs, the registry from §7 contains every tool defined in the plugins folder.

Try this: Combine §7 and §8 in your head: an operator adds my_tool.py containing @tools.register("my_tool"), and simply importing it makes the agent able to call it — no core code changes.

The plugin pattern, end to endRegistry (§7) + importlib discovery (§8) + __init_subclass__ (§6) is the complete recipe for an extensible agent: operators add a my_tool.py to the plugins folder, it self-registers on import, and the agent can call it — no core code changes. Real frameworks add entry points (declared in pyproject.toml, see P6) so pip-installed packages can contribute tools.

Exercises advanced

Practice
  1. Extend DotConfig to support assignment (cfg.model = "x") writing back into _data.
  2. Write a @timed decorator that records each call's duration into a shared list, then a @cached one (a mini lru_cache).
  3. Build a Typed descriptor that enforces a declared type on assignment (TypeError otherwise).
  4. Convert the ToolBase auto-registry to also record each tool's docstring as its description.
  5. Write load_plugins that skips modules starting with _ and logs how many tools each added.

🎯 Interview practice interview

The interview questions this topic gets asked — worked, with code. For the full pattern catalog see A9 · Big Tech AI-engineering patterns.

Design a plugin/tool registry (LLD round)

"Make tools pluggable." A decorator registers by name; dispatch is an O(1) dict lookup.

pythonclass Registry:
    def __init__(self): self._tools = {}
    def register(self, name):
        def deco(fn):
            self._tools[name] = fn
            return fn
        return deco
    def call(self, name, **kw):
        return self._tools[name](**kw)

tools = Registry()
@tools.register("scale")
def scale(replicas): return f"scaled to {replicas}"
▶ How this works

This is the interview-sized version of the registry from §7 — the same idea trimmed to what you'd write on a whiteboard. Interviewers ask it to see whether you understand decorators and dictionary dispatch.

  1. Registry holds one dict, self._tools, mapping a name to a function.
  2. register(name) returns the inner decorator deco, which stores the function under name and returns it unchanged — the decorator-factory pattern in its shortest form.
  3. call(name, **kw) simply looks the function up by name and calls it with the given keyword arguments.
  4. @tools.register("scale") above def scale(replicas) registers the function the instant the file is imported.

Try this: Be ready to explain why register returns a function that returns a function: the outer call captures the name, the inner deco captures the decorated function. That two-step is exactly what @decorator(arg) needs.

A typed, validating field with a descriptor

Explains what @property / ORM columns do under the hood — a senior signal.

pythonclass Bounded:
    def __init__(self, lo, hi): self.lo, self.hi = lo, hi
    def __set_name__(self, owner, name): self.attr = "_" + name
    def __get__(self, obj, t=None): return getattr(obj, self.attr)
    def __set__(self, obj, v):
        if not (self.lo <= v <= self.hi):
            raise ValueError(f"{v} out of range")
        setattr(obj, self.attr, v)
▶ How this works

The interview-sized descriptor — the compact cousin of Bounded from §4. Being able to write this from memory signals you understand what @property and ORM/Pydantic fields do underneath.

  1. __set_name__ captures the attribute's name and derives a private storage key (_name) so the value has somewhere real to live.
  2. __get__ returns that stored value on reads; __set__ validates the range on writes and raises ValueError if it's outside [lo, hi].
  3. The key insight to state out loud: the descriptor is a class attribute, but it stores each instance's value on the instance (via the _name slot), so two objects don't clash.

Try this: Explain when you'd reach for a descriptor over a plain @property: a descriptor is reusable across many fields and classes, whereas a @property is written once per attribute.

Checkpoint advanced

  • Explain the attribute-lookup order and when __getattr__ fires.
  • Make an object callable and know when that beats a closure.
  • Read @property/ORM/Pydantic fields as descriptors.
  • Write a parameterized, stateful decorator with functools.wraps.
  • Build a decorator-based tool registry and load plugins with importlib — and know to avoid metaclasses unless truly needed.

🪜 Practice ladder beginner → industry

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

Exercise 1 · __call__: an object that acts like a functionBeginner

Context: __call__ lets an object carry state and be used like a function — the hook behind configurable callables, partials, and stateful handlers.

Your task: Write a class Adder whose instances are callable, so Adder(10)(5) returns 15.

Requirements:

  • Store the base value at construction
  • Implement __call__ so the instance is invocable with the second operand
  • Adder(10)(5) returns 15
  • callable(instance) is True

💡 Hint: The constructor captures state; __call__ is what makes the instance behave like a function when invoked.

Show solution

__call__ makes instances invocable, so an object can carry state and be used like a function.

class Adder:
    def __init__(self, base):
        self.base = base
    def __call__(self, x):
        return self.base + x

add10 = Adder(10)
print(add10(5))        # 15
print(callable(add10)) # True
Exercise 2 · __getattr__ lazy proxy + attribute accessIntermediate

Context: __getattr__ fires only when normal attribute lookup fails — the mechanism behind lazy proxies and dotted-config objects, without the recursion trap of __getattribute__.

Your task: Build a Config that reads dict keys via attribute access (cfg.host) and raises AttributeError for unknown keys.

Requirements:

  • Attribute access like cfg.host returns the matching dict value
  • Unknown keys raise AttributeError (not KeyError)
  • Implement it via __getattr__ so only missing attributes are intercepted
  • Avoid infinite recursion when accessing the backing store

💡 Hint: Store the backing dict without going through normal attribute setting, so __getattr__ can reach it without recursing on itself.

Show solution

__getattr__ fires only for attributes not found normally — the hook behind proxies and config objects.

class Config:
    def __init__(self, data):
        object.__setattr__(self, "_data", data)
    def __getattr__(self, name):
        try:
            return self._data[name]
        except KeyError:
            raise AttributeError(name)

cfg = Config({"host": "db.local", "port": 5432})
print(cfg.host, cfg.port)   # db.local 5432
try:
    cfg.missing
except AttributeError as e:
    print("no such key:", e)
Exercise 3 · A descriptor behind @propertyAdvanced

Context: A descriptor implementing __get__/__set__ is the mechanism under @property and Pydantic-style validated fields — validation that lives on the class, enforced per instance.

Your task: Implement a Positive descriptor that validates an assigned value is > 0, so a class can declare qty = Positive().

Requirements:

  • Assigning a value > 0 stores it and reads back correctly
  • Assigning ≤ 0 raises ValueError
  • The descriptor is declared once in the class body (qty = Positive())
  • Per-instance state is stored per object, not shared across instances

💡 Hint: Use __set_name__ to learn the attribute name so each instance can key its own backing storage.

Show solution

A descriptor implements __get__/__set__; the class body binds it, and instances store per-object state keyed by name.

class Positive:
    def __set_name__(self, owner, name):
        self.attr = "_" + name
    def __get__(self, obj, owner=None):
        if obj is None:
            return self
        return getattr(obj, self.attr)
    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError("must be positive")
        setattr(obj, self.attr, value)

class Order:
    qty = Positive()
    def __init__(self, qty):
        self.qty = qty

print(Order(3).qty)   # 3
try:
    Order(-1)
except ValueError as e:
    print("rejected:", e)
Exercise 4 · Stateful, parameterized decoratorExpert

Context: A parameterized decorator is a factory returning a decorator, and functools.wraps preserves the wrapped function's identity — the pattern behind retry/cache/rate-limit wrappers.

Your task: Write a retry(times) decorator that retries a function up to times on exception and counts total attempts across calls, preserving the function's metadata.

Requirements:

  • retry(times) is a factory: it takes the argument and returns the decorator
  • The wrapper retries up to times on exception and re-raises the last one if all fail
  • It tracks attempt count as state on the wrapper
  • functools.wraps preserves __name__ and other metadata
  • A function that succeeds on a later attempt returns normally

💡 Hint: Three nested layers: the factory takes times, the decorator takes the function, and the closure holds the retry loop and the counter.

Show solution

A parameterized decorator is a factory returning a decorator; functools.wraps preserves identity; a closure/attribute holds state.

import functools

def retry(times):
    def deco(fn):
        @functools.wraps(fn)
        def wrapper(*a, **k):
            wrapper.attempts += 1
            last = None
            for _ in range(times):
                wrapper.attempts_this_call = _ + 1
                try:
                    return fn(*a, **k)
                except Exception as e:
                    last = e
            raise last
        wrapper.attempts = 0
        return wrapper
    return deco

calls = {"n": 0}
@retry(3)
def flaky():
    calls["n"] += 1
    if calls["n"] < 3:
        raise RuntimeError("boom")
    return "ok"

print(flaky())            # ok (took 3 tries)
print(flaky.__name__)     # flaky (wraps preserved)
Exercise 5 · A dynamic tool registryProfessional

Context: A decorator-based registry — register a function by name into a dict and dispatch by name — is exactly how agents expose tools to an LLM.

Your task: Build a tool registry where @tool registers a function by name into a dict and call(name, **kwargs) dispatches to it.

Requirements:

  • @tool registers the function under its name and returns it unchanged (still directly callable)
  • The registry is a module-level dict keyed by function name
  • call(name, **kwargs) dispatches to the registered function
  • Calling an unknown name raises a clear error (e.g. KeyError)

💡 Hint: The decorator registers on definition and returns the function untouched, so it stays usable both directly and via call.

Show solution

The registry is a module-level dict; the decorator registers on definition and returns the function unchanged so it stays directly callable.

REGISTRY = {}

def tool(fn):
    REGISTRY[fn.__name__] = fn
    return fn

@tool
def add(a, b):
    return a + b

@tool
def greet(name):
    return f"hi {name}"

def call(_name, **kwargs):
    if _name not in REGISTRY:
        raise KeyError(f"unknown tool: {_name}")
    return REGISTRY[_name](**kwargs)

print(sorted(REGISTRY))          # ['add', 'greet']
print(call("add", a=2, b=3))     # 5
print(call("greet", name="Ada")) # hi Ada
Exercise 6 · Runtime plugin loader with a metaclass registryIndustry scenario

Context: Auto-discovering plugins by having a base class register every subclass is how agent frameworks make importing a plugin module enough to expose its tools — and __init_subclass__ is the modern, readable form of the metaclass trick.

Your task: Design the mechanism an agent framework uses to auto-discover tool plugins: a base class whose subclass hook registers every subclass, so importing a plugin makes its tools available without manual wiring. Show it working.

Requirements:

  • Subclassing the base auto-registers the subclass into a central table keyed by a declared name
  • Abstract bases (no name) are skipped
  • A duplicate name is rejected (raises) rather than silently shadowing
  • A dispatch function looks up and runs a tool by name with no manual registry edits
  • Prefer __init_subclass__ over a full metaclass for readability
  • Note the loader should still discover plugin modules explicitly rather than importing arbitrary code

💡 Hint: Class creation triggers registration, so importing a plugin file is enough — __init_subclass__ runs at definition time, no metaclass needed.

Show solution

Design: a metaclass hook (__init_subclass__ is the modern, simpler form) registers each subclass into a central table keyed by a declared name. Importing a plugin file triggers class creation, which triggers registration — zero manual registry edits.

REGISTRY = {}

class Tool:
    name = None
    def __init_subclass__(cls, **kw):
        super().__init_subclass__(**kw)
        if cls.name:                       # skip abstract bases
            if cls.name in REGISTRY:
                raise ValueError(f"duplicate tool {cls.name!r}")
            REGISTRY[cls.name] = cls
    def run(self, **kwargs):
        raise NotImplementedError

# --- what a plugin module contains ---
class Search(Tool):
    name = "search"
    def run(self, q):
        return f"results for {q!r}"

class Calc(Tool):
    name = "calc"
    def run(self, expr):
        return eval(expr, {"__builtins__": {}})   # sandboxed builtins

# --- framework dispatch, no manual wiring ---
def invoke(name, **kwargs):
    return REGISTRY[name]().run(**kwargs)

print(sorted(REGISTRY))               # ['calc', 'search']
print(invoke("search", q="gil"))      # results for 'gil'
print(invoke("calc", expr="6*7"))     # 42

Production notes: __init_subclass__ beats a full metaclass for readability; the duplicate-name check prevents silent shadowing when two plugins clash; and the loader should still discover plugin modules explicitly (walking a plugins package) rather than importing arbitrary code — auto-registration removes wiring, not the security boundary.

Knowledge check check yourself

✓ Knowledge check

The lesson stresses that __getattr__ fires only when normal attribute lookup fails, unlike __getattribute__. Why does that distinction matter when building a lazy client or dotted-config proxy?

Show answer
Because it only runs on missing attributes, __getattr__ lets you fake or lazily produce attributes that don't really exist without intercepting every access — avoiding the infinite-recursion trap that makes __getattribute__ dangerous and rarely needed.
✓ Knowledge check

Section 6 shows __init_subclass__ as the modern alternative to a metaclass for auto-registering subclasses. Why does the lesson steer you toward it and away from metaclasses?

Show answer
__init_subclass__ (or a class decorator/descriptor) achieves the same definition-time registration far more simply; metaclasses are "deeper magic than 99% of users should ever worry about," so you should recognize them in framework code but almost never write one.
© 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