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.
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.
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.
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)
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.
- 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 anAttributeError. - Inside
Config,debugis set at the class level, so it is a class attribute — one copy shared by everyConfigobject.self.nameis set inside__init__, so it is an instance attribute — a private copy living on that one object. - Every object keeps its own instance attributes in a hidden dictionary called
__dict__. Soc.nameis found inc.__dict__, butc.debugis not there — Python doesn't give up, it looks at the class next and finds it. 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.)
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]
__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.
DotConfigstores a plain dictionary inself._data. When you ask forcfg.modeland there is no realmodelattribute, Python calls__getattr__(self, "model"), which looks the key up in_dataand returns it.- If the key is missing it raises
AttributeError— the correct error for "no such attribute". Thefrom Nonejust hides the internalKeyErrorso the message stays clean. - The clever line is
return DotConfig(v) if isinstance(v, dict) else v: if a value is itself a dict, it gets wrapped in anotherDotConfig. That is whycfg.limits.rpmchains with dots all the way down. LazyClientuses the same hook to defer expensive work. The real client isNoneuntil 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.
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.
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
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.
Tool.__init__stores aname, a functionfn, and a countercallsthat starts at 0 — ordinary per-instance state.__call__(self, **kwargs)is the special method that runs when you put parentheses after the object. It bumpsself.callsby one, then hands the arguments to the storedfnand returns its result.scale = Tool("scale", lambda replicas: ...)builds one such object; thelambdais just a tiny inline function used as the tool's body.scale(replicas=3)looks like a function call but actually triggers__call__. Afterwardsscale.callsis1— 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.
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]
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.
Boundedholds 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.__get__runs on reads. It fetches the real value from the private slot withgetattr(obj, self.attr). (Theif obj is Noneguard handles access on the class itself rather than an instance.)__set__runs on writes and is where validation happens: if the value is outside[lo, hi]it raisesValueError; otherwise it stores it withsetattr(obj, self.attr, value).- In
GenConfig,temperature = Bounded(0.0, 1.0)attaches a descriptor to the field. So the ordinary-looking lineself.temperature = temperaturein__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.
@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.
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
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.
- 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. - Layer 2
decorator(fn)receives the function being decorated (herecall_api) and returns its replacement. - Layer 3
wrapper(*args, **kwargs)is what actually runs when you call the function. It loops up totimes, returns on success, and on a matching exception sleeps withtime.sleep(2 ** attempt)(waits 1s, then 2s, then 4s…) before retrying. @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 lastre-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.
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.
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}
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.
ToolBasekeeps a shared dictionaryregistry = {}at the class level.__init_subclass__(cls, name, **kw)is a special hook that runs once, when a subclass is defined (not when instances are created).clsis the new subclass being created.- 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. class ScaleTool(ToolBase, name="scale")passesname="scale"right in the class definition line; that value arrives as thenameargument 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.
__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.
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']
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.
Registry.__init__creates an empty dictself._toolsthat will mapname → function.register(self, name)is a decorator factory: you call it with a name, and it returns the real decoratordeco. Insidedeco, the function is stored under that name and then returned unchanged — so the original function still works normally.dispatch(self, name, **kwargs)is the lookup: it checks the name exists (raisingKeyErrorif 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.@tools.register("get_pods")abovedef 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.
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.
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)
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.
importlib.import_module(package_name)imports the plugins package (a folder of Python files) so we can inspect it.pkgutil.iter_modules(package.__path__)walks that folder and yields the name of every module inside it — this is the "discovery" step.- 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. - 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.
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
- Extend
DotConfigto support assignment (cfg.model = "x") writing back into_data. - Write a
@timeddecorator that records each call's duration into a shared list, then a@cachedone (a minilru_cache). - Build a
Typeddescriptor that enforces a declared type on assignment (TypeErrorotherwise). - Convert the
ToolBaseauto-registry to also record each tool's docstring as its description. - Write
load_pluginsthat 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.
"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}"
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.
Registryholds one dict,self._tools, mapping a name to a function.register(name)returns the inner decoratordeco, which stores the function undernameand returns it unchanged — the decorator-factory pattern in its shortest form.call(name, **kw)simply looks the function up by name and calls it with the given keyword arguments.@tools.register("scale")abovedef 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.
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)
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.
__set_name__captures the attribute's name and derives a private storage key (_name) so the value has somewhere real to live.__get__returns that stored value on reads;__set__validates the range on writes and raisesValueErrorif it's outside[lo, hi].- 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
_nameslot), 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.
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 15callable(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
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.hostreturns the matching dict value - Unknown keys raise
AttributeError(notKeyError) - 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)
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)
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
timeson exception and re-raises the last one if all fail - It tracks attempt count as state on the wrapper
functools.wrapspreserves__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)
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:
@toolregisters 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
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")) # 42Production 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
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
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?