Decoradores de clase y decoradores con estado en Python

Cuando el estado de un decorador crece, un closure empieza a sentirse forzado. Python te permite usar cualquier callable como decorador, y una clase con __call__ es exactamente eso: un callable cuyo estado vive de forma natural en atributos de instancia, no en variables capturadas por una función anidada.

La mecánica es directa. Cuando escribes @MiDecorador encima de una función, Python ejecuta MiDecorador(fn) y reemplaza el nombre de la función con el resultado. Si MiDecorador es una clase, ese resultado es una instancia de la clase. Cada vez que llamas a la función decorada, Python invoca instancia.__call__(...). El estado del decorador —contadores, cachés, timestamps— vive en self con toda la claridad de un atributo de instancia.

¿Cuándo merece la pena el cambio? Cuando tienes dos o más variables de estado que interactúan entre sí, cuando necesitas resetear o inspeccionar ese estado desde fuera, o cuando el closure empieza a parecerse a una clase disfrazada de función. Un rate limiter, un contador de llamadas con umbral, una caché con política de expiración: todos ellos son casos donde la clase gana en legibilidad.

Lo que rompe si te equivocas: olvidar @functools.wraps dentro de __call__ (o su equivalente manual) hace que la función decorada pierda su __name__, __doc__ y __wrapped__, lo que rompe herramientas como help(), pytest y cualquier introspección que espere la identidad original.

import functools
import time
from collections import deque


class RateLimiter:
    """Permite como máximo `max_calls` llamadas en una ventana de `period` segundos."""

    def __init__(self, max_calls: int, period: float):
        self.max_calls = max_calls
        self.period = period
        self._timestamps: deque[float] = deque()
        self._fn = None  # se asigna en __call__ durante la decoración

    def __call__(self, *args, **kwargs):
        # Primera vez que se llama: recibimos la función a decorar
        if self._fn is None and len(args) == 1 and callable(args[0]):
            fn = args[0]
            self._fn = fn
            # Preservamos la identidad de la función original
            functools.update_wrapper(self, fn)
            return self

        # Llamadas posteriores: aplicamos la lógica de rate limiting
        now = time.monotonic()
        # Descartamos timestamps fuera de la ventana
        while self._timestamps and now - self._timestamps[0] > self.period:
            self._timestamps.popleft()

        if len(self._timestamps) >= self.max_calls:
            oldest = self._timestamps[0]
            wait = self.period - (now - oldest)
            raise RuntimeError(
                f"Límite alcanzado: {self.max_calls} llamadas en {self.period}s. "
                f"Reintenta en {wait:.2f}s."
            )

        self._timestamps.append(now)
        return self._fn(*args, **kwargs)

    def reset(self):
        """Permite resetear el estado desde el exterior — algo imposible con un closure."""
        self._timestamps.clear()


@RateLimiter(max_calls=3, period=10.0)
def fetch_data(url: str) -> str:
    return f"datos de {url}"


# --- Decorador de clase que modifica una CLASE, no una función ---

def add_repr(cls):
    """
    Decorador de clase: añade un __repr__ genérico basado en __dict__
    si la clase no define uno propio.
    """
    if "__repr__" not in cls.__dict__:  # __dict__ evita heredar el de object
        def __repr__(self):
            attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
            return f"{cls.__name__}({attrs})"
        cls.__repr__ = __repr__
    return cls


@add_repr
class Config:
    def __init__(self, host: str, port: int):
        self.host = host
        self.port = port


if __name__ == "__main__":
    print(fetch_data("https://api.ejemplo.com/datos"))
    print(fetch_data("https://api.ejemplo.com/datos"))
    print(fetch_data("https://api.ejemplo.com/datos"))

    try:
        fetch_data("https://api.ejemplo.com/datos")  # cuarta llamada: explota
    except RuntimeError as e:
        print(e)

    fetch_data.reset()  # reseteamos el estado — llamada 4 ahora es válida
    print(fetch_data("https://api.ejemplo.com/datos"))

    cfg = Config("localhost", 8080)
    print(cfg)  # Config(host='localhost', port=8080)

Qué está pasando realmente

RateLimiter tiene un truco de inicialización que vale la pena entender. La clase usa un patrón de doble invocación: @RateLimiter(max_calls=3, period=10.0) primero crea una instancia con la configuración (vía __init__), y luego Python llama a esa instancia con la función como argumento. El __call__ detecta ese primer contacto comprobando self._fn is None. A partir de ese momento, el mismo __call__ sirve como wrapper de cada llamada real.

El estado reside en self._timestamps, una deque que actúa como ventana deslizante: en cada invocación se purgan los timestamps más antiguos que period segundos antes de contar. Esto es O(n) en el peor caso pero n está acotado a max_calls, así que en la práctica es O(1).

El método reset() ilustra la ventaja semántica sobre un closure. Con un closure necesitarías exponer una función auxiliar o usar una lista mutable como hack. Con una clase, reset es un método de primera clase que cualquier consumidor puede llamar con fetch_data.reset(), porque fetch_data es la instancia del decorador.

functools.update_wrapper(self, fn) copia __name__, __qualname__, __doc__, __dict__ y __wrapped__ desde fn hacia la instancia del decorador. Es el equivalente de @functools.wraps pero aplicado a instancias en lugar de funciones internas.

El decorador add_repr funciona de forma completamente distinta: recibe una clase, la modifica directamente y devuelve la misma clase. Fíjate en cls.__dict__ en lugar de hasattr: hasattr encontraría el __repr__ heredado de object, mientras que cls.__dict__ solo mira lo que la clase define explícitamente. Es un detalle pequeño con un impacto real: si una subclase ya define su propio __repr__, no lo pisamos.

Errores que debes conocer

Error: usar @RateLimiter sin paréntesis cuando el __init__ espera argumentos hace que Python pase la función como max_calls, lo que provoca un TypeError críptico en tiempo de decoración.

# ❌ Wrong
@RateLimiter          # Python llama RateLimiter(fetch_data) → max_calls recibe una función
def fetch_data(url):
    ...

# ✅ Right
@RateLimiter(max_calls=3, period=10.0)
def fetch_data(url):
    ...

Los paréntesis crean la instancia primero; sin ellos la clase recibe la función directamente en __init__.


Error: olvidar functools.update_wrapper (o no llamarlo antes de devolver self) hace que fetch_data.__name__ sea "RateLimiter" y que herramientas de introspección fallen de formas no obvias.

# ❌ Wrong — __call__ devuelve self sin actualizar los metadatos
def __call__(self, *args, **kwargs):
    if self._fn is None and callable(args[0]):
        self._fn = args[0]
        return self  # los metadatos de la función original se pierden

# ✅ Right
def __call__(self, *args, **kwargs):
    if self._fn is None and callable(args[0]):
        self._fn = args[0]
        functools.update_wrapper(self, args[0])  # copia __name__, __doc__, __wrapped__
        return self

update_wrapper necesita ejecutarse antes de que la instancia reemplace a la función en el namespace del módulo.


Error: compartir estado mutable entre instancias al definir atributos de clase en lugar de atributos de instancia.

# ❌ Wrong — todos los decoradores comparten el mismo deque
class RateLimiter:
    _timestamps = deque()  # atributo de clase: compartido entre todas las instancias

# ✅ Right
class RateLimiter:
    def __init__(self, max_calls, period):
        self._timestamps = deque()  # atributo de instancia: aislado por decorador

Cada función decorada necesita su propia ventana de tiempo; si el deque es de clase, decorar dos funciones distintas las hace competir por el mismo límite.

119

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio