AI EngineeringZero to ProductionHome·About·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

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.

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

__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.)

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

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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