Module 9: Metaclasses — When Everything Else Is Not Enough¶
Table of Contents¶
- Introduction
- Visual: Tooling Power Ladder
- Visual: Class Creation Pipeline
- Core 41:
type(name, bases, namespace)— Manual Class Creation - Core 42:
class X(metaclass=MyMeta)— Resolution, Timing, Conflicts - Core 43: Metaclass
__new__vs__init__ - Core 44:
__prepare__— Declaration-Time Enforcement - Capstone:
PluginMeta— Automatic Plugin Registration - Glossary (Module 9)
Introduction¶
Metaclasses are Python’s lowest-level, highest-impact hook for customizing class creation. They sit beneath:
- Descriptors /
@property(Modules 7–8): per-attribute semantics. - Class decorators (Module 6): post-creation, opt-in class transformation.
Metaclasses intervene before a class exists, during the class statement itself, and they apply automatically across the inheritance tree.
Rule of thumb (non-negotiable):
Use a metaclass only when you must enforce a class-creation invariant across a whole hierarchy, at definition time, and descriptors + class decorators are insufficient.
All python fences are runnable. Any intentional failure is wrapped.
Visual: Tooling Power Ladder¶
Tooling Power Ladder (higher = more magic, higher risk)
┌──────────────────────────────────────────────────────────┐
│ 4 Metaclasses (this module) │ ← full class creation control
│ 3 Class decorators (Module 6) │ ← post-creation transformation
│ 2 Descriptors / @property (Modules 7–8) │ ← per-attribute access control
│ 1 Plain code │ ← explicit, predictable (prefer)
└──────────────────────────────────────────────────────────┘
Caption: Always select the lowest viable level on the ladder.
Visual: Class Creation Pipeline¶
Class Creation Pipeline (conceptual)
Source code:
class C(Base1, Base2, metaclass=M):
x = 1
def f(self): ...
Pipeline steps (executed at import/definition time):
1. Resolve metaclass M (explicit or derived from bases)
2. ns = M.__prepare__("C", (Base1, Base2), **kw) ← optional custom mapping
3. Execute class body into ns ← assignments, defs, etc.
4. cls = M("C", (Base1, Base2), ns, **kw)
├─ M.__new__(...) ← create class object (mutable ns)
└─ M.__init__(...) ← post-creation init/registration
5. Bind resulting class object to name C in scope
Caption: Entire pipeline runs before any instance of C can exist.
Core 41: type(name, bases, namespace) — Manual Class Creation¶
Canonical idea¶
type is the default metaclass. In its 3-argument form it constructs a class dynamically.
Meaning¶
namebecomes__name__(and a simple__qualname__).basesdrives MRO and inheritance.namespaceis copied into the class dict (exposed as a read-onlymappingproxy).
Example: build classes from data¶
def make_greeter_class(class_name: str, greeting: str):
def greet(self):
return f"{greeting} from {self.__class__.__name__}"
ns = {
"__doc__": f"{class_name} generated by type()",
"greet": greet,
"tag": "generated",
}
return type(class_name, (object,), ns)
Hello = make_greeter_class("Hello", "Hi")
h = Hello()
print(h.greet()) # Hi from Hello
print(Hello.tag) # generated
print(Hello.__doc__) # Hello generated by type()
Precision note: __module__¶
A class statement injects __module__ automatically. If you call type(...) directly and care about repr/pickling/docs, set it yourself.
def make_class_in_module(name: str, module: str):
ns = {"__module__": module, "x": 1}
return type(name, (object,), ns)
C = make_class_in_module("C", "my_project.models")
print(C.__module__) # my_project.models
Exercise¶
Implement dynamic_dataclass(fields: dict[str, type]) -> type using only type(...) that generates:
__init__with a sane signature (inspect.Signature)__repr__
Core 42: class X(metaclass=MyMeta) — Resolution, Timing, Conflicts¶
Timing (no illusions)¶
Everything in the metaclass pipeline runs at definition time (typically import time). If your metaclass does I/O or heavy work, you are paying it during import.
Visual: Metaclass Resolution¶
Metaclass Resolution (high-level)
Given: class C(Base1, Base2, ..., metaclass=Explicit?)
Resolution rules:
┌─ Explicit metaclass provided? ──► Use it (compatibility check)
│
└─ No explicit → derive from bases
├─ All bases share a common “most-derived” metaclass?
│ → Use that
└─ No compatible common metaclass?
→ TypeError: metaclass conflict
Caption: Conflicts are the most common metaclass surprise in multiple inheritance.
Example: a metaclass that injects a tag¶
class SimpleMeta(type):
def __new__(mcs, name, bases, ns):
ns["tag"] = f"created by {mcs.__name__}"
return super().__new__(mcs, name, bases, ns)
class Base(metaclass=SimpleMeta):
pass
class Child(Base):
pass # inherits metaclass implicitly
print(Base.tag) # created by SimpleMeta
print(Child.tag) # created by SimpleMeta
Example: metaclass conflict (expected failure)¶
class MetaA(type):
pass
class MetaB(type):
pass
class A(metaclass=MetaA):
pass
class B(metaclass=MetaB):
pass
try:
class Bad(A, B):
pass
except TypeError as e:
print("Expected:", e)
Fix pattern: a joint metaclass (only if semantically valid)¶
class MetaAB(MetaA, MetaB):
pass
class A2(metaclass=MetaAB):
pass
class B2(metaclass=MetaAB):
pass
class OK(A2, B2):
pass
print(type(OK) is MetaAB) # True
Core 43: Metaclass __new__ vs __init__¶
Visual: the split¶
Metaclass __new__ vs __init__ Split
Metaclass.__new__(mcs, name, bases, ns, **kw)
┌──────────────────────────────────────────────┐
│ Receives mutable namespace (ns) │
│ Ideal for: │
│ • structural edits (inject/rename attrs) │
│ • rewriting bases │
│ • validation needing full namespace │
└──────────────────────────────────────────────┘
│
▼
Creates and returns class object
Metaclass.__init__(cls, name, bases, ns, **kw)
┌──────────────────────────────────────────────┐
│ Receives created class (cls) │
│ cls.__dict__ is now read-only mappingproxy │
│ Ideal for: │
│ • registration │
│ • post-creation validation (final MRO) │
│ • bookkeeping (use setattr(cls, ...)) │
└──────────────────────────────────────────────┘
Caption: Structural changes in __new__; bookkeeping/registration in __init__.
Example: enforce an interface + register classes¶
class ValidatingMeta(type):
registry = []
def __new__(mcs, name, bases, ns):
has_run_here = "run" in ns
has_run_in_bases = any(hasattr(b, "run") for b in bases)
if not has_run_here and not has_run_in_bases:
raise TypeError(f"{name} must define run()")
ns["from_meta"] = lambda self: "ok"
return super().__new__(mcs, name, bases, ns)
def __init__(cls, name, bases, ns):
super().__init__(name, bases, ns)
ValidatingMeta.registry.append(cls)
class Task(metaclass=ValidatingMeta):
def run(self):
return "running"
print(Task().from_meta()) # ok
print(Task in ValidatingMeta.registry) # True
try:
class BadTask(metaclass=ValidatingMeta):
pass
except TypeError as e:
print("Expected:", e)
Exercise¶
Write AutoReprMeta that injects __repr__ in __new__ using __annotations__:
- include only public annotated fields
- handle missing attributes:
getattr(self, name, "<missing>")
Core 44: __prepare__ — Declaration-Time Enforcement¶
Visual: why __prepare__ is unique¶
__prepare__ Timing (why it is unique)
Without __prepare__:
┌──────────────────────────────┐
│ class body executes into │
│ ordinary dict (order preserved)│
│ → metaclass sees final ns only│
└──────────────────────────────┘
With __prepare__:
┌──────────────────────────────┐
│ Custom mapping supplied │
│ before any body statement │
│ → can intercept/validate │
│ each assignment as it happens│
└──────────────────────────────┘
Caption: __prepare__ enables declaration-time enforcement impossible otherwise.
Example: forbid duplicate assignment (except dunders)¶
class NoDupesMeta(type):
class NoDupesDict(dict):
def __setitem__(self, key, value):
is_dunder = key.startswith("__") and key.endswith("__")
if key in self and not is_dunder:
raise ValueError(f"Duplicate assignment to {key!r}")
super().__setitem__(key, value)
@classmethod
def __prepare__(mcs, name, bases, **kw):
return NoDupesMeta.NoDupesDict()
class OK(metaclass=NoDupesMeta):
x = 1
y = 2
print(OK.x, OK.y) # 1 2
try:
class Bad(metaclass=NoDupesMeta):
x = 1
x = 2
except ValueError as e:
print("Expected:", e)
Exercise¶
Write TracingMeta:
__prepare__returns a mapping that records assignment order__new__stores that order as__definition_order__
Capstone: PluginMeta — Automatic Plugin Registration¶
This demonstrates the main “honest” use case for metaclasses: automatic, hierarchy-wide enforcement and registration.
Design goals:
- Base classes opt out via
__abstract__ = True - Subclasses register by
group - Duplicate names rejected within a group
- Registry is testable via
clear()
Visual: metaclass-driven registry¶
Metaclass-Driven Registry (import-time behavior)
Module import
│
▼
class Plugin(... , metaclass=PluginMeta):
│
├─ class body executes into namespace
└─ PluginMeta.__new__ runs
├─ validate invariants (e.g., no duplicates)
└─ register class into _registry[group]
Subsequent imports → additional registrations
All concrete subclasses register automatically
Caption: Registration is automatic and hierarchy-wide (infectious).
Implementation (runnable, testable)¶
from collections import defaultdict
from threading import RLock
from typing import Dict, List, Tuple, Type, Optional
_registry: Dict[str, List[Tuple[str, Type[object]]]] = defaultdict(list)
_lock = RLock()
class PluginMeta(type):
@classmethod
def __prepare__(mcs, name, bases, **kw):
return dict()
def __new__(mcs, name, bases, ns, **kw):
cls = super().__new__(mcs, name, bases, ns)
if ns.get("__abstract__", False):
return cls
group = ns.get("group")
if group is None:
for b in bases:
if hasattr(b, "group"):
group = getattr(b, "group")
break
if group is None:
group = "default"
cls.group = group
with _lock:
items = _registry[group]
if any(existing_name == name for existing_name, _ in items):
raise ValueError(f"Duplicate plugin {name!r} in group {group!r}")
items.append((name, cls))
items.sort(key=lambda t: t[0])
return cls
@classmethod
def get_plugins(mcs, group: str) -> List[Tuple[str, Type[object]]]:
with _lock:
return list(_registry.get(group, []))
@classmethod
def clear(mcs, group: Optional[str] = None) -> None:
with _lock:
if group is None:
_registry.clear()
else:
_registry.pop(group, None)
# Usage
class Logger(metaclass=PluginMeta):
__abstract__ = True
group = "logging"
def log(self, msg: str) -> str:
raise NotImplementedError
class FileLogger(Logger):
def log(self, msg: str) -> str:
return f"[FILE] {msg}"
class ConsoleLogger(Logger):
def log(self, msg: str) -> str:
return f"[CONSOLE] {msg}"
print([name for name, _ in PluginMeta.get_plugins("logging")])
# ['ConsoleLogger', 'FileLogger']
Caveats (must be stated bluntly)¶
- Import-order dependent registration.
- Reload can double-register unless you clear/reset.
- Metaclass conflicts with other frameworks’ metaclasses are common.
- Global registries complicate tests unless you expose reset hooks (we did).
Exercise¶
Extend the capstone:
- Add
priority: int = 0and sort by(-priority, name). - Add
disable(name: str, group: str)to remove an entry. - Add tests that call
PluginMeta.clear()between runs.
Glossary (Module 9)¶
| Term | Definition |
|---|---|
| Metaclass | The “class of a class”; customizes how classes are created and initialized. |
type(name, bases, ns) |
The primitive 3-arg constructor that builds a class dynamically (what a class statement ultimately uses). |
| Class creation pipeline | The definition-time sequence: resolve metaclass → __prepare__ → execute body → metaclass __new__ → metaclass __init__. |
| Definition time / import time | When class bodies execute and metaclass hooks run; heavy work here slows imports and can create side effects. |
| Metaclass resolution | The rule set that chooses the effective metaclass from an explicit metaclass= or from base classes. |
| Metaclass conflict | TypeError raised when bases’ metaclasses have no compatible common “most-derived” metaclass in multiple inheritance. |
| Joint metaclass | A metaclass that subclasses multiple metaclasses to satisfy conflict rules (only valid if the behaviors compose). |
__prepare__ |
Metaclass hook returning the namespace mapping used while executing the class body (enables declaration-time enforcement). |
| Custom namespace mapping | A dict-like object returned by __prepare__ that can intercept assignments (e.g., forbid duplicates, record order). |
| Declaration-time enforcement | Constraints enforced during class body execution (via custom namespace), impossible to implement reliably after the fact. |
Metaclass __new__ |
Constructs the class object from (name, bases, ns); best place for structural edits and namespace-driven validation. |
Metaclass __init__ |
Runs after class creation; best place for registration and bookkeeping using setattr (final MRO is known). |
| Namespace mutability window | The phase where ns is still mutable (inside metaclass __new__); after creation, cls.__dict__ is a read-only mappingproxy. |
mappingproxy |
Read-only view of a class’s dict (cls.__dict__), preventing direct mutation post-creation. |
| Class decorators | Post-creation, opt-in class transformations (less invasive than metaclasses; don’t affect subclasses automatically). |
| Hierarchy-wide invariants | Rules enforced automatically for every subclass in an inheritance tree (the main legitimate reason to use a metaclass). |
| Automatic registration | Pattern where concrete subclasses register themselves at definition time into a registry (e.g., plugins). |
| Abstract base opt-out | Convention like __abstract__ = True to prevent base classes from being registered/enforced as concrete plugins. |
| Registry | A global or metaclass-owned mapping from keys (e.g., group) to classes; must be resettable for tests. |
| Duplicate-name rejection | Enforcing uniqueness constraints at class creation (e.g., no two plugins with the same name in one group). |
| Import-order dependence | Registration side effects depend on what modules import first; can cause missing/extra registrations across runs. |
| Reload hazard | Module reload can re-run class definitions and double-register unless the registry is cleared or guarded. |
| Test isolation hook | A clear()/reset API to make global registries deterministic in unit tests. |
| Infectious behavior | Metaclass effects propagate to subclasses automatically (useful for invariants, risky for surprises). |
| MRO (method resolution order) | The linearization that determines attribute lookup across bases; finalized after class creation (relevant to __init__). |
__module__ injection |
class statements set __module__ automatically; manual type(...) creation should set it when repr/pickling/docs matter. |
| Signature injection | Attaching __signature__ (or generating __init__) during class creation to control introspection and call semantics. |
| Metaclass vs descriptor | Metaclasses coordinate class-level structure and invariants; descriptors implement per-attribute behavior once the class exists. |
| Power ladder | Heuristic: prefer plain code → descriptors → class decorators → metaclasses; metaclasses are the highest-magic, highest-risk tool. |
Proceed to Module 10.