Metaprogramming is code that manipulates code — creating classes at runtime, intercepting attribute access, or building DSLs. Python’s dynamic nature makes it unusually powerful here.

Descriptors

Descriptors control attribute access via __get__, __set__, and __delete__:

  class Validator:
    def __init__(self, min_value, max_value):
        self.min_value = min_value
        self.max_value = max_value

    def __set_name__(self, owner, name):
        self.name = f"_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.name)

    def __set__(self, obj, value):
        if not (self.min_value <= value <= self.max_value):
            raise ValueError(f"{self.name} must be between {self.min_value} and {self.max_value}")
        setattr(obj, self.name, value)

class Product:
    price = Validator(0, 10000)
    quantity = Validator(0, 1000)

    def __init__(self, price, quantity):
        self.price = price
        self.quantity = quantity

p = Product(29.99, 10)
p.price = -5  # ValueError
  

@property is implemented using descriptors under the hood.

Dynamic Attribute Access

  class DynamicObject:
    def __init__(self, **kwargs):
        self._data = kwargs

    def __getattr__(self, name):
        if name in self._data:
            return self._data[name]
        raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")

    def __setattr__(self, name, value):
        if name == "_data":
            super().__setattr__(name, value)
        else:
            self._data[name] = value

obj = DynamicObject(name="Alice", age=30)
print(obj.name)  # Alice
  

Metaclasses

Metaclasses are “classes of classes” — they control class creation:

  class SingletonMeta(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class Database(metaclass=SingletonMeta):
    pass

db1 = Database()
db2 = Database()
assert db1 is db2
  

Use metaclasses sparingly — they add complexity. Often a decorator or __init_subclass__ is cleaner.

init_subclass — Register Subclasses

A Pythonic alternative to metaclasses for plugin systems:

  class PluginBase:
    registry = {}

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        PluginBase.registry[cls.__name__] = cls

class EmailPlugin(PluginBase):
    pass

class SlackPlugin(PluginBase):
    pass

print(PluginBase.registry)
# {'EmailPlugin': <class 'EmailPlugin'>, 'SlackPlugin': <class 'SlackPlugin'>}
  

Decorators as Metaprogramming

Decorators modify functions at definition time:

  import functools
import time

def retry(max_attempts=3, delay=1):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    if attempt == max_attempts - 1:
                        raise
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, delay=2)
def fetch_data():
    ...
  

exec and eval (Use with Caution)

  # eval — evaluate an expression
result = eval("2 + 3 * 4")  # 14

# exec — execute statements
namespace = {}
exec("x = 42\ny = x * 2", namespace)
print(namespace["y"])  # 84
  

Never use eval/exec on untrusted input — it’s a security risk.

When to Use Metaprogramming

  • ORM field definitions (Django models, SQLAlchemy columns)
  • Validation frameworks (Pydantic, attrs)
  • Plugin architectures
  • API client generators

Avoid metaprogramming when plain functions and classes would be clearer. The best metaprogramming is invisible to the user of your library.